progress(@RequestParam String redisKey, @RequestParam String taskId) {
+ AsyncTaskProgressVO progress = asyncTaskProgressService.get(redisKey, taskId);
+ return progress == null ? R.failed("异步任务不存在或已过期") : R.ok(progress);
+ }
+}
diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskAction.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskAction.java
new file mode 100644
index 000000000..ae64e038c
--- /dev/null
+++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskAction.java
@@ -0,0 +1,16 @@
+package com.bsd.pjcloud.common.asynctask.service;
+
+/**
+ * 通用异步任务业务动作。
+ * 动作在后台事务中执行,抛出异常会回滚整个动作并把 Redis 任务标记为失败。
+ */
+@FunctionalInterface
+public interface AsyncTaskAction {
+
+ /**
+ * 执行具体异步业务。
+ *
+ * @param reporter 逐项进度报告器,业务完成一个计数项目后调用一次
+ */
+ void execute(AsyncTaskProgressReporter reporter);
+}
diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskException.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskException.java
new file mode 100644
index 000000000..d2cedadd7
--- /dev/null
+++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskException.java
@@ -0,0 +1,7 @@
+package com.bsd.pjcloud.common.asynctask.service;
+
+/** 允许展示给任务发起人的受控错误;禁止传入 SQL、Token 或底层异常文本。 */
+public class AsyncTaskException extends RuntimeException {
+ /** 使用业务可读提示终止任务,不暴露底层异常。 */
+ public AsyncTaskException(String message) { super(message); }
+}
diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskExecutorService.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskExecutorService.java
new file mode 100644
index 000000000..a4c866eb6
--- /dev/null
+++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskExecutorService.java
@@ -0,0 +1,34 @@
+package com.bsd.pjcloud.common.asynctask.service;
+
+import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
+
+/**
+ * 通用异步事务任务提交服务。
+ * 同步创建 Redis 任务后,把业务动作交给后台线程执行,并透传当前租户和登录用户上下文。
+ */
+public interface AsyncTaskExecutorService {
+
+ /**
+ * 创建并提交一个异步事务任务。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskName 任务展示名称
+ * @param total 需要处理的总项目数
+ * @param action 后台事务中执行的业务动作
+ * @return 等待执行的初始任务进度,调用方可立即把任务 ID 返回客户端
+ */
+ AsyncTaskProgressVO submit(String redisKey, String taskName, int total, AsyncTaskAction action);
+
+ /**
+ * 创建并提交一个可在成功终态携带结果文件的异步事务任务。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskName 任务展示名称
+ * @param total 需要处理的总项目数
+ * @param action 后台事务中执行并返回结果文件信息的业务动作
+ * @return 等待执行的初始任务进度,调用方可立即把任务 ID 返回客户端
+ */
+ default AsyncTaskProgressVO submitWithResult(String redisKey, String taskName, int total, AsyncTaskResultAction action) {
+ throw new UnsupportedOperationException("当前异步任务执行器不支持返回任务结果");
+ }
+}
diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressReporter.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressReporter.java
new file mode 100644
index 000000000..638b5c2c3
--- /dev/null
+++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressReporter.java
@@ -0,0 +1,27 @@
+package com.bsd.pjcloud.common.asynctask.service;
+
+/**
+ * 异步任务逐项进度报告器。
+ * 业务每完成一个可计数项目后调用一次,由通用执行器递增 Redis 进度。
+ */
+@FunctionalInterface
+public interface AsyncTaskProgressReporter {
+
+ /**
+ * 在后台任务取得真实数据量后更新总项目数。
+ * 适用于查询本身耗时、必须先返回任务 ID 的导出场景;固定总数任务无需调用。
+ *
+ * @param total 后台查询得到的真实总项目数,必须大于零
+ * @param message 总数更新后的进度说明;为空时使用通用说明
+ */
+ default void updateTotal(int total, String message) {
+ throw new UnsupportedOperationException("当前异步任务进度报告器不支持动态更新总数");
+ }
+
+ /**
+ * 报告一个项目已处理完成。
+ *
+ * @param message 当前项目完成后的进度说明;为空时使用通用说明
+ */
+ void advance(String message);
+}
diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressService.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressService.java
new file mode 100644
index 000000000..15e3d0584
--- /dev/null
+++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressService.java
@@ -0,0 +1,91 @@
+package com.bsd.pjcloud.common.asynctask.service;
+
+import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
+
+/**
+ * 通用异步任务 Redis 进度服务。
+ * 业务 Key 按当前用户隔离,租户 ID 显式写入服务端任务键;调用方只提供业务命名空间和任务 ID。
+ */
+public interface AsyncTaskProgressService {
+
+ /**
+ * 创建等待执行的异步任务并写入 Redis。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskName 任务展示名称
+ * @param total 需要处理的总项目数,必须大于零
+ * @return 已写入 Redis 的初始任务进度
+ */
+ AsyncTaskProgressVO create(String redisKey, String taskName, int total);
+
+ /**
+ * 将任务标记为执行中。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskId 不透明任务 ID
+ */
+ void start(String redisKey, String taskId);
+
+ /**
+ * 在任务开始后更新真实总项目数并重新计算完成百分比。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskId 不透明任务 ID
+ * @param total 后台查询得到的真实总项目数,必须大于零
+ * @param message 总数更新后的进度说明
+ */
+ default void updateTotal(String redisKey, String taskId, int total, String message) {
+ throw new UnsupportedOperationException("当前异步任务进度服务不支持动态更新总数");
+ }
+
+ /**
+ * 更新任务执行进度。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskId 不透明任务 ID
+ * @param completed 当前已处理项目数
+ * @param message 当前进度说明
+ */
+ void update(String redisKey, String taskId, int completed, String message);
+
+ /**
+ * 将任务标记为成功并把进度置为 100%。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskId 不透明任务 ID
+ * @param message 最终成功说明
+ */
+ void success(String redisKey, String taskId, String message);
+
+ /**
+ * 将任务标记为成功,并在实现支持时保存业务动作返回的结果文件信息。
+ * 默认实现退化为原有成功状态写入,保证既有自定义进度服务无需同步修改;
+ * 需要向调用端返回附件的实现应覆写本方法。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskId 不透明任务 ID
+ * @param message 最终成功说明
+ * @param result 业务动作返回的可选结果文件信息
+ */
+ default void success(String redisKey, String taskId, String message, AsyncTaskResult result) {
+ success(redisKey, taskId, message);
+ }
+
+ /**
+ * 将任务标记为失败并保留失败前执行进度。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskId 不透明任务 ID
+ * @param message 对调用端安全的失败说明
+ */
+ void fail(String redisKey, String taskId, String message);
+
+ /**
+ * 查询当前用户创建的异步任务进度。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskId 不透明任务 ID
+ * @return 任务进度;任务不存在、已过期或不属于当前用户时返回 {@code null}
+ */
+ AsyncTaskProgressVO get(String redisKey, String taskId);
+}
diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskResult.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskResult.java
new file mode 100644
index 000000000..2b4f26b07
--- /dev/null
+++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskResult.java
@@ -0,0 +1,40 @@
+package com.bsd.pjcloud.common.asynctask.service;
+
+/**
+ * 通用异步任务成功结果。
+ * 导入等无附件任务可返回 {@link #empty()},导出任务可返回下载地址和文件名称;结果只会在任务成功终态写入进度对象。
+ *
+ * @param fileUrl 任务生成文件的下载地址;无结果文件时为 {@code null}
+ * @param fileName 下载时使用的业务文件名称;无结果文件时为 {@code null}
+ */
+public record AsyncTaskResult(String fileUrl, String fileName) {
+
+ /**
+ * 创建通用异步任务成功结果。
+ *
+ * @param fileUrl 任务生成文件的下载地址;无结果文件时为 {@code null}
+ * @param fileName 下载时使用的业务文件名称;无结果文件时为 {@code null}
+ */
+ public AsyncTaskResult {
+ }
+
+ /**
+ * 创建不携带结果文件的成功结果。
+ *
+ * @return 无文件地址和名称的任务结果
+ */
+ public static AsyncTaskResult empty() {
+ return new AsyncTaskResult(null, null);
+ }
+
+ /**
+ * 创建携带下载文件的成功结果。
+ *
+ * @param fileUrl 项目现有文件中心返回的文件下载地址
+ * @param fileName 下载时使用的业务文件名称
+ * @return 包含结果文件信息的任务结果
+ */
+ public static AsyncTaskResult file(String fileUrl, String fileName) {
+ return new AsyncTaskResult(fileUrl, fileName);
+ }
+}
diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskResultAction.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskResultAction.java
new file mode 100644
index 000000000..67fb6d6fe
--- /dev/null
+++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskResultAction.java
@@ -0,0 +1,17 @@
+package com.bsd.pjcloud.common.asynctask.service;
+
+/**
+ * 携带成功结果的通用异步任务业务动作。
+ * 用于导出等需要在成功终态返回结果文件的任务;动作在后台事务中执行,抛出异常会回滚并标记任务失败。
+ */
+@FunctionalInterface
+public interface AsyncTaskResultAction {
+
+ /**
+ * 执行具体异步业务并返回成功结果。
+ *
+ * @param reporter 逐项进度报告器,业务完成一个计数项目后调用一次
+ * @return 任务成功终态需要返回调用端的可选结果文件信息
+ */
+ AsyncTaskResult execute(AsyncTaskProgressReporter reporter) throws Exception;
+}
diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/BatchExportSupport.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/BatchExportSupport.java
new file mode 100644
index 000000000..7ecb6bf3e
--- /dev/null
+++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/BatchExportSupport.java
@@ -0,0 +1,99 @@
+package com.bsd.pjcloud.common.asynctask.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate;
+import com.bsd.pjcloud.common.excel.stream.TemplateBatchWorkbook;
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.springframework.core.io.ClassPathResource;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+/** 导出业务共用的分页、进度和完整文件响应适配,不改变业务字段转换。 */
+public final class BatchExportSupport {
+ /** 一个查询批次的最大明细数,与模板流式行窗口一致。 */
+ public static final int BATCH_SIZE = 200;
+ /** XLSX 的附件响应类型。 */
+ public static final String CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+
+ private BatchExportSupport() { }
+
+ /** 按已有模板分页生成;仅在文件关闭后交给消费者上传或返回同步响应。 */
+ public static R template(String template, Map values,
+ Function> fetcher, Function> converter,
+ AsyncTaskProgressReporter reporter, BatchExcelExportTemplate.FileConsumer consumer,
+ Consumer customizer) throws Exception {
+ Page first = fetcher.apply(1L);
+ long total = first.getTotal();
+ if (total == 0) throw new AsyncTaskException("当前筛选条件没有可导出的数据");
+ if (reporter != null) {
+ reporter.updateTotal(BatchExcelExportTemplate.calculateProgressTotal(total, BATCH_SIZE, 2),
+ "共 " + total + " 条,正在分批生成 Excel");
+ }
+ try {
+ return TemplateBatchWorkbook.write(() -> new ClassPathResource(template).getInputStream(), total, BATCH_SIZE,
+ values, (current, size) -> current == 1 ? first.getRecords() : fetcher.apply(current).getRecords(),
+ converter, (completed, expected) -> {
+ if (reporter != null) reporter.advance("已写入 " + completed + " / " + expected + " 条");
+ }, file -> {
+ if (reporter != null) reporter.advance("文件已生成,正在保存导出结果");
+ return consumer.consume(file);
+ }, customizer);
+ } catch (IllegalArgumentException exception) {
+ // 模板行数校验的文案为受控提示,其他异常仍交给执行器统一处理。
+ if (exception.getMessage() != null && exception.getMessage().startsWith("导出明细数超过")) {
+ throw new AsyncTaskException(exception.getMessage() + ",请缩小筛选范围");
+ }
+ throw exception;
+ } catch (IllegalStateException exception) {
+ if (exception.getMessage() != null && exception.getMessage().startsWith("导出数据在执行期间发生变化")) {
+ throw new AsyncTaskException("导出期间数据发生变化,请重试");
+ }
+ throw exception;
+ }
+ }
+
+ /** 保持旧同步接口的附件语义;生成失败时尚未写出任何响应文件字节。 */
+ public static Void respond(Path file, String fileName, HttpServletResponse response) throws Exception {
+ response.setContentType(CONTENT_TYPE);
+ response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" +
+ URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20"));
+ Files.copy(file, response.getOutputStream());
+ response.flushBuffer();
+ return null;
+ }
+
+ /** 延续旧模板的时间范围标题字段;缺失时间条件时占位符留空。 */
+ public static Map timeValues(LocalDateTime[] range) {
+ Map values = new HashMap<>();
+ if (range != null && range.length == 2 && range[0] != null && range[1] != null) {
+ DateTimeFormatter format = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+ values.put("startTime", range[0].format(format));
+ values.put("endTime", range[1].format(format));
+ values.put("timeSpan", " 到 ");
+ }
+ return values;
+ }
+
+ /** 钱包日期区间沿用零点时间标题,不改变实际日期筛选。 */
+ public static Map timeValues(java.time.LocalDate[] range) {
+ if (range == null || range.length != 2) return new HashMap<>();
+ return timeValues(new LocalDateTime[]{range[0] == null ? null : range[0].atStartOfDay(), range[1] == null ? null : range[1].atStartOfDay()});
+ }
+
+ /** 创建受导出批大小约束的分页,后续页不重复执行 COUNT。 */
+ public static Page page(long current) {
+ Page page = new Page<>(current, BATCH_SIZE, current == 1);
+ page.setMaxLimit((long) BATCH_SIZE);
+ return page;
+ }
+}
diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskExecutorService.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskExecutorService.java
new file mode 100644
index 000000000..8b9353c6e
--- /dev/null
+++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskExecutorService.java
@@ -0,0 +1,217 @@
+package com.bsd.pjcloud.common.asynctask.service.impl;
+
+import com.bsd.pjcloud.common.asynctask.service.AsyncTaskAction;
+import com.bsd.pjcloud.common.asynctask.service.AsyncTaskException;
+import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
+import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
+import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressService;
+import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult;
+import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResultAction;
+import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
+import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
+import com.bsd.pjcloud.common.security.util.NonWebTokenContextHolder;
+import com.bsd.pjcloud.common.security.util.SecurityUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.core.task.TaskExecutor;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.context.SecurityContextHolderStrategy;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * 通用异步事务任务提交服务默认实现。
+ * 实现会恢复提交线程的租户、安全和 Feign Token 上下文,并以单个 Spring 事务执行完整业务动作。
+ */
+@Slf4j
+public class DefaultAsyncTaskExecutorService implements AsyncTaskExecutorService {
+
+ /** 通用异步任务日志标签,用于统一检索任务提交和执行异常。 */
+ private static final String LOG_TAG = "通用异步任务";
+
+ /** 后台事务失败时返回调用端的固定说明,避免暴露 SQL 或远程调用等内部异常细节。 */
+ private static final String TASK_FAILED_MESSAGE = "导出任务执行失败,请重试";
+
+ /** 线程池拒绝任务时返回调用端的固定说明,提示稍后重新提交。 */
+ private static final String TASK_REJECTED_MESSAGE = "任务提交失败,请稍后重试";
+
+ /** 公共异步任务工作线程池,用于限制后台任务并发并与业务专用线程池隔离。 */
+ private final TaskExecutor asyncTaskWorkerExecutor;
+
+ /** Redis 任务进度服务,用于初始化、更新和查询任务状态。 */
+ private final AsyncTaskProgressService asyncTaskProgressService;
+
+ /** Spring 编程式事务模板,保证整个异步业务动作成功或整体回滚。 */
+ private final TransactionTemplate transactionTemplate;
+
+ /**
+ * 创建通用异步任务执行器。
+ *
+ * @param asyncTaskWorkerExecutor 公共异步任务专用工作线程池
+ * @param asyncTaskProgressService Redis 任务进度服务
+ * @param transactionTemplate Spring 编程式事务模板
+ */
+ public DefaultAsyncTaskExecutorService(TaskExecutor asyncTaskWorkerExecutor,
+ AsyncTaskProgressService asyncTaskProgressService, TransactionTemplate transactionTemplate) {
+ this.asyncTaskWorkerExecutor = asyncTaskWorkerExecutor;
+ this.asyncTaskProgressService = asyncTaskProgressService;
+ this.transactionTemplate = transactionTemplate;
+ }
+
+ /**
+ * 创建并提交一个异步事务任务。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskName 任务展示名称
+ * @param total 需要处理的总项目数
+ * @param action 后台事务中执行的业务动作
+ * @return 等待执行的初始任务进度
+ */
+ @Override
+ public AsyncTaskProgressVO submit(String redisKey, String taskName, int total, AsyncTaskAction action) {
+ return submitWithResult(redisKey, taskName, total, reporter -> {
+ action.execute(reporter);
+ return AsyncTaskResult.empty();
+ });
+ }
+
+ /**
+ * 创建并提交一个可在成功终态携带结果文件的异步事务任务。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskName 任务展示名称
+ * @param total 需要处理的总项目数
+ * @param action 后台事务中执行并返回结果文件信息的业务动作
+ * @return 等待执行的初始任务进度
+ */
+ @Override
+ public AsyncTaskProgressVO submitWithResult(String redisKey, String taskName, int total, AsyncTaskResultAction action) {
+ AsyncTaskProgressVO progress = asyncTaskProgressService.create(redisKey, taskName, total);
+ Long tenantId = TenantContextHolder.getTenantId();
+ Authentication authentication = SecurityUtils.getAuthentication();
+ String token = SecurityUtils.getToken();
+ SecurityContextHolderStrategy strategy = SecurityContextHolder.getContextHolderStrategy();
+ SecurityContext securityContext = strategy.createEmptyContext();
+ securityContext.setAuthentication(authentication);
+ try {
+ asyncTaskWorkerExecutor.execute(() -> runTask(progress, tenantId, token, strategy, securityContext, action));
+ }
+ catch (RuntimeException exception) {
+ log.error("|{}|任务提交线程池失败,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception);
+ markFailedSafely(progress, TASK_REJECTED_MESSAGE);
+ throw new AsyncTaskException(TASK_REJECTED_MESSAGE);
+ }
+ return progress;
+ }
+
+ /**
+ * 在恢复租户、安全和 Feign Token 上下文后执行异步事务。
+ *
+ * @param progress 初始任务进度
+ * @param tenantId 请求提交时的租户 ID,恢复查询及任务键的租户隔离上下文
+ * @param token 请求提交时的 OAuth2 Token,可为空
+ * @param strategy 当前 Spring Security 上下文策略
+ * @param securityContext 请求提交时复制出的安全上下文
+ * @param action 后台事务中执行的业务动作
+ */
+ private void runTask(AsyncTaskProgressVO progress, Long tenantId, String token, SecurityContextHolderStrategy strategy,
+ SecurityContext securityContext, AsyncTaskResultAction action) {
+ try {
+ TenantContextHolder.setTenantId(tenantId);
+ strategy.setContext(securityContext);
+ NonWebTokenContextHolder.setToken(token);
+ asyncTaskProgressService.start(progress.getRedisKey(), progress.getTaskId());
+ AtomicInteger completed = new AtomicInteger();
+ AsyncTaskResult result;
+ try {
+ AsyncTaskProgressReporter reporter = createProgressReporter(progress, completed);
+ result = transactionTemplate.execute(status -> {
+ try { return action.execute(reporter); }
+ catch (RuntimeException exception) { throw exception; }
+ catch (Exception exception) { throw new IllegalStateException("导出执行异常", exception); }
+ });
+ }
+ catch (Exception exception) {
+ log.error("|{}|任务事务执行失败,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception);
+ markFailedSafely(progress, exception instanceof AsyncTaskException ? exception.getMessage() : TASK_FAILED_MESSAGE);
+ return;
+ }
+ markSuccessSafely(progress, result);
+ }
+ catch (Exception exception) {
+ log.error("|{}|任务启动失败,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception);
+ markFailedSafely(progress, exception instanceof AsyncTaskException ? exception.getMessage() : TASK_FAILED_MESSAGE);
+ }
+ finally {
+ NonWebTokenContextHolder.clear();
+ strategy.clearContext();
+ TenantContextHolder.clear();
+ }
+ }
+
+ /**
+ * 创建同时支持逐项推进和动态总数更新的任务进度报告器。
+ *
+ * @param progress 当前任务的业务命名空间和任务 ID
+ * @param completed 当前任务线程内的已处理计数器
+ * @return 绑定当前任务 Redis 进度的报告器
+ */
+ private AsyncTaskProgressReporter createProgressReporter(AsyncTaskProgressVO progress, AtomicInteger completed) {
+ return new AsyncTaskProgressReporter() {
+ /**
+ * 将后台查询得到的真实总数写入 Redis,并同步约束线程内已处理计数。
+ *
+ * @param total 真实总项目数,必须大于零
+ * @param message 总数更新后的进度说明
+ */
+ @Override
+ public void updateTotal(int total, String message) {
+ completed.updateAndGet(current -> Math.min(current, total));
+ asyncTaskProgressService.updateTotal(progress.getRedisKey(), progress.getTaskId(), total, message);
+ }
+
+ /**
+ * 将已处理项目数递增一项并写入 Redis。
+ *
+ * @param message 当前项目完成后的进度说明
+ */
+ @Override
+ public void advance(String message) {
+ int current = completed.incrementAndGet();
+ asyncTaskProgressService.update(progress.getRedisKey(), progress.getTaskId(), current, message);
+ }
+ };
+ }
+
+ /**
+ * 尝试把任务写为失败状态;Redis 异常只记录日志,不能覆盖原始业务异常或阻断线程上下文清理。
+ *
+ * @param progress 待更新的任务标识和业务命名空间
+ * @param message 对调用端安全的失败说明
+ */
+ private void markFailedSafely(AsyncTaskProgressVO progress, String message) {
+ try {
+ asyncTaskProgressService.fail(progress.getRedisKey(), progress.getTaskId(), message);
+ }
+ catch (Exception exception) {
+ log.error("|{}|写入失败进度异常,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception);
+ }
+ }
+
+ /**
+ * 尝试把已提交事务的任务写为成功状态;Redis 写入失败只记录,不能把已经提交的数据误报为已回滚。
+ *
+ * @param progress 已完成数据库事务的任务标识和业务命名空间
+ * @param result 业务动作返回的可选结果文件信息
+ */
+ private void markSuccessSafely(AsyncTaskProgressVO progress, AsyncTaskResult result) {
+ try {
+ asyncTaskProgressService.success(progress.getRedisKey(), progress.getTaskId(), "任务执行成功", result);
+ }
+ catch (Exception exception) {
+ log.error("|{}|写入成功进度异常,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception);
+ }
+ }
+}
diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskProgressService.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskProgressService.java
new file mode 100644
index 000000000..e78d1d3a9
--- /dev/null
+++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskProgressService.java
@@ -0,0 +1,262 @@
+package com.bsd.pjcloud.common.asynctask.service.impl;
+
+import com.bsd.pjcloud.common.asynctask.constant.AsyncTaskStatusEnum;
+import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult;
+import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressService;
+import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
+import com.bsd.pjcloud.common.security.service.PjcloudUser;
+import com.bsd.pjcloud.common.security.util.SecurityUtils;
+import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
+import org.springframework.data.redis.core.RedisTemplate;
+
+import java.time.Duration;
+import java.util.UUID;
+import java.util.regex.Pattern;
+
+/**
+ * 通用异步任务 Redis 进度服务默认实现。
+ * 该实现显式拼接租户、业务命名空间、登录用户和任务 ID,兼容项目当前普通字符串 Redis Key 序列化器。
+ */
+public class DefaultAsyncTaskProgressService implements AsyncTaskProgressService {
+
+ /** 所有通用异步任务 Redis Key 的固定业务命名空间。 */
+ private static final String REDIS_KEY_PREFIX = "async:task:progress";
+
+ /** 任务状态的 Redis 保留时间,供页面刷新或短时重连后继续查询。 */
+ private static final Duration TASK_TTL = Duration.ofHours(24);
+
+ /** 业务 Redis Key 允许的字符和长度,防止调用方构造不可控的 Redis 路径。 */
+ private static final Pattern REDIS_KEY_PATTERN = Pattern.compile("^[a-zA-Z0-9:_-]{1,80}$");
+
+ /** 不透明任务 ID 的固定格式,与本服务生成的 32 位 UUID 一致。 */
+ private static final Pattern TASK_ID_PATTERN = Pattern.compile("^[a-fA-F0-9]{32}$");
+
+ /** Redis 操作模板,使用项目统一 JSON 序列化配置保存进度对象。 */
+ private final RedisTemplate redisTemplate;
+
+ /**
+ * 创建通用 Redis 进度服务。
+ *
+ * @param redisTemplate 项目统一配置的 Redis 操作模板
+ */
+ public DefaultAsyncTaskProgressService(RedisTemplate redisTemplate) {
+ this.redisTemplate = redisTemplate;
+ }
+
+ /**
+ * 创建等待执行的异步任务并写入 Redis。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskName 任务展示名称
+ * @param total 需要处理的总项目数,必须大于零
+ * @return 已写入 Redis 的初始任务进度
+ */
+ @Override
+ public AsyncTaskProgressVO create(String redisKey, String taskName, int total) {
+ validateRedisKey(redisKey);
+ if (total <= 0) {
+ throw new IllegalArgumentException("异步任务总数必须大于0");
+ }
+ AsyncTaskProgressVO progress = new AsyncTaskProgressVO();
+ progress.setTaskId(UUID.randomUUID().toString().replace("-", ""));
+ progress.setRedisKey(redisKey);
+ progress.setTaskName(taskName);
+ progress.setStatus(AsyncTaskStatusEnum.PENDING.getStatus());
+ progress.setTotal(total);
+ progress.setCompleted(0);
+ progress.setPercentage(0);
+ progress.setMessage(AsyncTaskStatusEnum.PENDING.getDescription());
+ progress.setFinished(false);
+ progress.setSuccess(false);
+ save(progress);
+ return progress;
+ }
+
+ /**
+ * 将任务标记为执行中。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskId 不透明任务 ID
+ */
+ @Override
+ public void start(String redisKey, String taskId) {
+ AsyncTaskProgressVO progress = requireProgress(redisKey, taskId);
+ progress.setStatus(AsyncTaskStatusEnum.RUNNING.getStatus());
+ progress.setMessage(AsyncTaskStatusEnum.RUNNING.getDescription());
+ save(progress);
+ }
+
+ /**
+ * 在任务开始后更新真实总项目数并重新计算完成百分比。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskId 不透明任务 ID
+ * @param total 后台查询得到的真实总项目数,必须大于零
+ * @param message 总数更新后的进度说明
+ */
+ @Override
+ public void updateTotal(String redisKey, String taskId, int total, String message) {
+ if (total <= 0) {
+ throw new IllegalArgumentException("异步任务总数必须大于0");
+ }
+ AsyncTaskProgressVO progress = requireProgress(redisKey, taskId);
+ int safeCompleted = Math.max(0, Math.min(progress.getCompleted(), total));
+ progress.setStatus(AsyncTaskStatusEnum.RUNNING.getStatus());
+ progress.setTotal(total);
+ progress.setCompleted(safeCompleted);
+ progress.setPercentage(calculatePercentage(safeCompleted, total));
+ progress.setMessage(message == null || message.isBlank() ? "正在处理" : message);
+ save(progress);
+ }
+
+ /**
+ * 更新任务执行进度。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskId 不透明任务 ID
+ * @param completed 当前已处理项目数
+ * @param message 当前进度说明
+ */
+ @Override
+ public void update(String redisKey, String taskId, int completed, String message) {
+ AsyncTaskProgressVO progress = requireProgress(redisKey, taskId);
+ int safeCompleted = Math.max(0, Math.min(completed, progress.getTotal()));
+ progress.setStatus(AsyncTaskStatusEnum.RUNNING.getStatus());
+ progress.setCompleted(safeCompleted);
+ progress.setPercentage(calculatePercentage(safeCompleted, progress.getTotal()));
+ progress.setMessage(message == null || message.isBlank() ? "正在处理" : message);
+ save(progress);
+ }
+
+ /**
+ * 将任务标记为成功并把进度置为 100%。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskId 不透明任务 ID
+ * @param message 最终成功说明
+ */
+ @Override
+ public void success(String redisKey, String taskId, String message) {
+ success(redisKey, taskId, message, null);
+ }
+
+ /**
+ * 将任务标记为成功、把进度置为 100%,并保存可选的结果文件信息。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskId 不透明任务 ID
+ * @param message 最终成功说明
+ * @param result 业务动作返回的可选结果文件信息
+ */
+ @Override
+ public void success(String redisKey, String taskId, String message, AsyncTaskResult result) {
+ AsyncTaskProgressVO progress = requireProgress(redisKey, taskId);
+ progress.setStatus(AsyncTaskStatusEnum.SUCCESS.getStatus());
+ progress.setCompleted(progress.getTotal());
+ progress.setPercentage(100);
+ progress.setMessage(message == null || message.isBlank() ? AsyncTaskStatusEnum.SUCCESS.getDescription() : message);
+ progress.setFinished(true);
+ progress.setSuccess(true);
+ if (result != null) {
+ progress.setResultFileUrl(result.fileUrl());
+ progress.setResultFileName(result.fileName());
+ }
+ save(progress);
+ }
+
+ /**
+ * 将任务标记为失败并保留失败前执行进度。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskId 不透明任务 ID
+ * @param message 对调用端安全的失败说明
+ */
+ @Override
+ public void fail(String redisKey, String taskId, String message) {
+ AsyncTaskProgressVO progress = requireProgress(redisKey, taskId);
+ progress.setStatus(AsyncTaskStatusEnum.FAILED.getStatus());
+ progress.setMessage(message == null || message.isBlank() ? AsyncTaskStatusEnum.FAILED.getDescription() : message);
+ progress.setFinished(true);
+ progress.setSuccess(false);
+ save(progress);
+ }
+
+ /**
+ * 查询当前用户创建的异步任务进度。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskId 不透明任务 ID
+ * @return 任务进度;任务不存在、已过期或不属于当前用户时返回 {@code null}
+ */
+ @Override
+ public AsyncTaskProgressVO get(String redisKey, String taskId) {
+ validateRedisKey(redisKey);
+ Object value = redisTemplate.opsForValue().get(buildRedisKey(redisKey, taskId));
+ return value instanceof AsyncTaskProgressVO progress ? progress : null;
+ }
+
+ /**
+ * 获取必须存在的任务进度。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskId 不透明任务 ID
+ * @return 当前任务进度
+ */
+ private AsyncTaskProgressVO requireProgress(String redisKey, String taskId) {
+ AsyncTaskProgressVO progress = get(redisKey, taskId);
+ if (progress == null) {
+ throw new IllegalStateException("异步任务不存在或已过期");
+ }
+ return progress;
+ }
+
+ /**
+ * 保存任务进度并刷新统一过期时间。
+ *
+ * @param progress 待保存的完整进度对象
+ */
+ private void save(AsyncTaskProgressVO progress) {
+ redisTemplate.opsForValue().set(buildRedisKey(progress.getRedisKey(), progress.getTaskId()), progress, TASK_TTL);
+ }
+
+ /**
+ * 构建按当前用户隔离的业务 Redis Key。
+ * 租户 ID 显式进入任务键,不依赖 Redis 序列化器隐式隔离。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ * @param taskId 不透明任务 ID
+ * @return 仅服务端使用的业务 Redis Key,结构为固定前缀、租户 ID、业务命名空间、用户 ID 和任务 ID
+ */
+ private String buildRedisKey(String redisKey, String taskId) {
+ PjcloudUser currentUser = SecurityUtils.getUser();
+ if (currentUser == null || currentUser.getId() == null) {
+ throw new IllegalStateException("缺少异步任务用户上下文");
+ }
+ if (taskId == null || !TASK_ID_PATTERN.matcher(taskId).matches()) {
+ throw new IllegalArgumentException("异步任务ID格式不正确");
+ }
+ return String.join(":", REDIS_KEY_PREFIX, String.valueOf(TenantContextHolder.getTenantId()), redisKey, String.valueOf(currentUser.getId()), taskId);
+ }
+
+ /**
+ * 校验业务 Redis Key 命名空间,限制长度与字符范围。
+ *
+ * @param redisKey 业务指定的 Redis Key 命名空间
+ */
+ private void validateRedisKey(String redisKey) {
+ if (redisKey == null || !REDIS_KEY_PATTERN.matcher(redisKey).matches()) {
+ throw new IllegalArgumentException("异步任务Redis Key格式不正确");
+ }
+ }
+
+ /**
+ * 计算取整后的完成百分比。
+ *
+ * @param completed 已完成项目数
+ * @param total 总项目数
+ * @return 0 至 99 的整数百分比;只有 success 终态可写入 100
+ */
+ private int calculatePercentage(int completed, int total) {
+ return total <= 0 ? 0 : (int) Math.min(99L, (long) completed * 100 / total);
+ }
+}
diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/vo/AsyncTaskProgressVO.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/vo/AsyncTaskProgressVO.java
new file mode 100644
index 000000000..37c8aa6c8
--- /dev/null
+++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/vo/AsyncTaskProgressVO.java
@@ -0,0 +1,66 @@
+package com.bsd.pjcloud.common.asynctask.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 通用异步任务进度展示对象。
+ * 对象存入 Redis 并通过通用进度接口返回,可用于导入、导出等后台任务。
+ */
+@Data
+@Schema(description = "通用异步任务进度")
+public class AsyncTaskProgressVO implements Serializable {
+
+ /** 序列化版本号,用于 Redis 中任务状态的兼容反序列化。 */
+ private static final long serialVersionUID = 1L;
+
+ /** 调用端轮询使用的不透明任务 ID,不包含租户或用户信息。 */
+ @Schema(description = "任务ID")
+ private String taskId;
+
+ /** 业务指定的 Redis Key 命名空间,用于区分不同异步任务场景。 */
+ @Schema(description = "Redis Key命名空间")
+ private String redisKey;
+
+ /** 任务展示名称,例如后台用户导入。 */
+ @Schema(description = "任务名称")
+ private String taskName;
+
+ /** 固定任务状态编码:PENDING、RUNNING、SUCCESS、FAILED。 */
+ @Schema(description = "任务状态")
+ private String status;
+
+ /** 任务需要处理的总项目数。 */
+ @Schema(description = "总项目数")
+ private Integer total;
+
+ /** 当前执行到的项目数;任务整体失败时仅表示回滚或失败前的执行位置,不代表最终成功写入数量。 */
+ @Schema(description = "已处理项目数")
+ private Integer completed;
+
+ /** 任务完成百分比,取值范围为 0 至 100。 */
+ @Schema(description = "完成百分比")
+ private Integer percentage;
+
+ /** 当前进度或最终结果说明。 */
+ @Schema(description = "任务消息")
+ private String message;
+
+ /** 是否已进入成功或失败终态,供调用端决定是否停止轮询。 */
+ @Schema(description = "是否完成")
+ private Boolean finished;
+
+ /** 终态是否成功;任务未结束时固定为 {@code false}。 */
+ @Schema(description = "是否成功")
+ private Boolean success;
+
+ /** 任务成功后生成文件的下载地址;导入等无附件任务为空。 */
+ @Schema(description = "任务结果文件下载地址")
+ private String resultFileUrl;
+
+ /** 任务成功后生成文件的下载名称;导入等无附件任务为空。 */
+ @Schema(description = "任务结果文件名称")
+ private String resultFileName;
+}
diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/pjcloud-common/pjcloud-common-async-task/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
new file mode 100644
index 000000000..16dfa6472
--- /dev/null
+++ b/pjcloud-common/pjcloud-common-async-task/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -0,0 +1,2 @@
+com.bsd.pjcloud.common.asynctask.config.AsyncTaskAutoConfiguration
+com.bsd.pjcloud.common.asynctask.config.AsyncTaskWebAutoConfiguration
diff --git a/pjcloud-common/pjcloud-common-bom/pom.xml b/pjcloud-common/pjcloud-common-bom/pom.xml
index aa59cd924..b4ee9fdd5 100644
--- a/pjcloud-common/pjcloud-common-bom/pom.xml
+++ b/pjcloud-common/pjcloud-common-bom/pom.xml
@@ -62,6 +62,11 @@
+
+ com.bsd
+ pjcloud-common-async-task
+ ${pjcloud.version}
+
com.bsd
pjcloud-common-core
diff --git a/pjcloud-common/pjcloud-common-excel/README.md b/pjcloud-common/pjcloud-common-excel/README.md
new file mode 100644
index 000000000..d05b7cd73
--- /dev/null
+++ b/pjcloud-common/pjcloud-common-excel/README.md
@@ -0,0 +1,56 @@
+# 导出默认样式 token
+
+`ExcelStyleTokens` 提供全局默认样式,配置前缀为 `excel.export-style`,可放在服务配置或 Nacos 公共配置中。未配置的值使用代码默认值,表头与正文可以分别设置字体、字号、加粗、文字颜色、背景颜色、行高、对齐、换行和缩进。
+
+当前仅 `/admin/user/export-async` 接入。原同步用户导出和其他业务导出的样式不受影响,已有模板无需修改。
+
+```yaml
+excel:
+ export-style:
+ header:
+ font-name: Microsoft YaHei
+ font-size: 11
+ bold: true
+ font-color: "#FFFFFF"
+ background-color: "#1F4E78"
+ row-height: 36
+ alignment: CENTER
+ wrap-text: true
+ indent: 0
+ body:
+ font-name: Microsoft YaHei
+ font-size: 11
+ bold: false
+ font-color: "#243247"
+ background-color: "#FFFFFF"
+ row-height: 26
+ alignment: LEFT
+ wrap-text: false
+ indent: 1
+ striped: true
+ alternate-row-color: "#F3F6FA"
+ border-style: THIN
+ border-color: "#DCE3EC"
+ column-width: 22
+ # 可选:按 Java 字段名覆盖业务默认列宽。[] 保留字段名大小写。
+ column-widths:
+ "[userId]": 24
+ "[username]": 20
+ "[deptName]": 28
+ "[roleNameList]": 30
+ freeze-header: true
+ auto-filter: true
+ show-grid-lines: false
+```
+
+颜色支持带或不带 `#` 的六位 RGB;YAML 中带 `#` 的颜色必须加引号。字号与行高单位为磅,列宽单位为 Excel 字符宽度(1~255)。配置绑定时校验颜色及尺寸。字体由打开 Excel 的电脑解析,可改为该电脑已安装的中文字体。边框样式与颜色应用于表头列分隔和正文水平分隔,正文不绘制完整网格。
+
+列宽优先级:配置中的 `column-widths` > 业务默认列宽 > `column-width`。用户导出已按编号、手机、邮箱、部门、角色和日期等字段分别设置列宽,不扫描全部明细来计算列宽。长文本保持原值,默认正文不换行;需要多行显示时可同时增加 `body.row-height` 并开启 `body.wrap-text`。开启交替行底纹时,偶数数据行使用 `alternate-row-color`,其余数据行使用 `body.background-color`。
+
+后续导出可显式注册处理器来复用样式(本次不接入其他接口):
+
+```java
+builder.registerWriteHandler(new ExcelExportStyleHandler(excelConfig.getExportStyle()));
+```
+
+每次导出创建独立处理器,在提交任务时固定当前 token。处理器只支持 XLSX,缓存工作簿中的字体与单元格样式,条纹按实际行号计算,适用于分批流式写入。现有转换器、单元格类型、数据格式及导出列保持原样。固定格式模板应继续使用自身样式,不自动注册此处理器。
diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/config/ExcelConfigProperties.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/config/ExcelConfigProperties.java
index d32d000d4..834607ae3 100644
--- a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/config/ExcelConfigProperties.java
+++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/config/ExcelConfigProperties.java
@@ -1,13 +1,19 @@
package com.bsd.pjcloud.common.excel.config;
+import com.bsd.pjcloud.common.excel.style.ExcelStyleTokens;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.context.properties.NestedConfigurationProperty;
+import org.springframework.validation.annotation.Validated;
/**
* @author pjcloud
* @date 2020/3/29
*/
@Data
+@Validated
@ConfigurationProperties(prefix = ExcelConfigProperties.PREFIX)
public class ExcelConfigProperties {
@@ -18,4 +24,12 @@ public class ExcelConfigProperties {
*/
private String templatePath = "excel";
+ /**
+ * 全局导出样式 token。只有显式注册 ExcelExportStyleHandler 的导出才会应用。
+ */
+ @Valid
+ @NotNull
+ @NestedConfigurationProperty
+ private ExcelStyleTokens exportStyle = new ExcelStyleTokens();
+
}
diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/BatchExcelExportTemplate.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/BatchExcelExportTemplate.java
new file mode 100644
index 000000000..1240086fc
--- /dev/null
+++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/BatchExcelExportTemplate.java
@@ -0,0 +1,617 @@
+package com.bsd.pjcloud.common.excel.stream;
+
+import org.apache.poi.ss.SpreadsheetVersion;
+import org.apache.poi.xssf.streaming.SXSSFSheet;
+import org.apache.poi.xssf.streaming.SXSSFWorkbook;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 基于 XLSX 模板的通用分批流式导出模板。
+ *
+ * 该模板统一管理分页循环、预统计数量校验、SXSSF 临时文件和最终 XLSX 临时文件,业务方只需提供
+ * 模板初始化、分页查询、逐行写入、尾部写入、进度通知和文件消费回调。该类不依赖异步任务或文件中心,
+ * 可由不同业务按需组合。
+ */
+public final class BatchExcelExportTemplate {
+
+ /** Excel 2007 单个工作表支持的最大行数。 */
+ private static final int XLSX_MAX_ROW_COUNT = SpreadsheetVersion.EXCEL2007.getMaxRows();
+
+ /** 默认分页大小和 SXSSF 内存行窗口大小。 */
+ private static final int DEFAULT_BATCH_SIZE = 1000;
+
+ /** 默认临时文件名前缀,必须满足 JDK 至少三个字符的约束。 */
+ private static final String DEFAULT_TEMPORARY_FILE_PREFIX = "batch-excel-export-";
+
+ /** 公共导出模板日志记录器,仅记录临时文件清理失败等基础设施异常。 */
+ private static final Logger LOGGER = LoggerFactory.getLogger(BatchExcelExportTemplate.class);
+
+ /**
+ * 工具类不允许实例化。
+ */
+ private BatchExcelExportTemplate() {
+ }
+
+ /**
+ * 创建一次分批导出的链式配置器。
+ *
+ * @param expectedTotal 导出前按相同筛选条件统计的明细总数,必须大于零
+ * @param 分页查询返回的业务行类型
+ * @param 模板初始化后供逐行及尾部写入共享的上下文类型
+ * @param 最终文件消费回调的返回类型
+ * @return 尚未执行的导出配置器
+ */
+ public static Builder builder(long expectedTotal) {
+ return new Builder<>(expectedTotal);
+ }
+
+ /**
+ * 根据总记录数和分页大小计算实际查询批次数。
+ *
+ * @param expectedTotal 导出前预统计的明细总数,必须大于零
+ * @param batchSize 单次分页查询大小,必须大于零
+ * @return 向上取整后的批次数
+ */
+ public static int calculateBatchCount(long expectedTotal, int batchSize) {
+ validatePositive(expectedTotal, "导出总数必须大于零");
+ validatePositive(batchSize, "导出批大小必须大于零");
+ long batchCount = (expectedTotal - 1) / batchSize + 1;
+ if (batchCount > Integer.MAX_VALUE) {
+ throw new IllegalArgumentException("导出批次数超过整数上限");
+ }
+ return (int) batchCount;
+ }
+
+ /**
+ * 计算当前 XLSX 工作表在指定布局下最多可写入的明细行数。
+ *
+ * @param dataStartRowIndex 明细开始写入的 Excel 零基行号
+ * @param reservedTailRowCount 明细之后为合计、备注等内容预留的行数
+ * @return 不超过 Excel 2007 单工作表上限的最大明细行数
+ */
+ public static int calculateMaxDetailRowCount(int dataStartRowIndex, int reservedTailRowCount) {
+ if (dataStartRowIndex < 0) {
+ throw new IllegalArgumentException("导出明细起始行不得小于零");
+ }
+ if (reservedTailRowCount < 0) {
+ throw new IllegalArgumentException("导出尾部预留行数不得小于零");
+ }
+ long maxDetailRowCount = (long) XLSX_MAX_ROW_COUNT - dataStartRowIndex - reservedTailRowCount;
+ if (maxDetailRowCount < 0) {
+ throw new IllegalArgumentException("导出固定行和尾部预留行超过单个工作表上限");
+ }
+ return (int) maxDetailRowCount;
+ }
+
+ /**
+ * 计算异步任务可使用的总进度步数。
+ *
+ * 每个查询批次占一步,文件上传、异步任务成功落库等非批次动作由业务方通过额外步数声明。
+ *
+ * @param expectedTotal 导出前预统计的明细总数,必须大于零
+ * @param batchSize 单次分页查询大小,必须大于零
+ * @param extraSteps 文件上传等额外步骤数量,不得小于零
+ * @return 批次数与额外步骤之和
+ */
+ public static int calculateProgressTotal(long expectedTotal, int batchSize, int extraSteps) {
+ if (extraSteps < 0) {
+ throw new IllegalArgumentException("导出额外进度步数不得小于零");
+ }
+ long progressTotal = (long) calculateBatchCount(expectedTotal, batchSize) + extraSteps;
+ if (progressTotal > Integer.MAX_VALUE) {
+ throw new IllegalArgumentException("导出进度总步数超过整数上限");
+ }
+ return (int) progressTotal;
+ }
+
+ /**
+ * 校验正整数参数。
+ *
+ * @param value 待校验数值
+ * @param message 参数非法时使用的异常信息
+ */
+ private static void validatePositive(long value, String message) {
+ if (value <= 0) {
+ throw new IllegalArgumentException(message);
+ }
+ }
+
+ /**
+ * 打开模板输入流的回调,返回的流由公共导出模板负责关闭。
+ */
+ @FunctionalInterface
+ public interface TemplateSupplier {
+
+ /**
+ * 打开本次导出使用的 XLSX 模板。
+ *
+ * @return 非空的模板输入流
+ * @throws Exception 模板不存在或无法读取时抛出
+ */
+ InputStream open() throws Exception;
+ }
+
+ /**
+ * 模板初始化回调,用于读取样式、替换标题和移除占位行。
+ *
+ * @param 后续逐行和尾部写入共享的上下文类型
+ */
+ @FunctionalInterface
+ public interface TemplateInitializer {
+
+ /**
+ * 在 XSSFWorkbook 转换为 SXSSFWorkbook 前初始化模板。
+ *
+ * @param workbook 可编辑的模板工作簿,生命周期由公共导出模板管理
+ * @return 后续写入共享的业务上下文,允许为空
+ * @throws Exception 模板结构不符合业务约束时抛出
+ */
+ C initialize(XSSFWorkbook workbook) throws Exception;
+ }
+
+ /**
+ * 分页数据查询回调,业务查询必须使用稳定且唯一的排序规则。
+ *
+ * @param 业务行类型
+ */
+ @FunctionalInterface
+ public interface PageFetcher {
+
+ /**
+ * 查询指定页的数据。
+ *
+ * @param pageNumber 从一开始的页码
+ * @param batchSize 单页最大记录数
+ * @return 当前页记录;无记录时可返回空列表或空值
+ * @throws Exception 数据查询失败时抛出
+ */
+ List fetch(long pageNumber, int batchSize) throws Exception;
+ }
+
+ /**
+ * 单行写入回调,业务可在此补充序号、格式化字段和累计跨批次汇总。
+ *
+ * @param 业务行类型
+ * @param 模板共享上下文类型
+ */
+ @FunctionalInterface
+ public interface RowWriter {
+
+ /**
+ * 将一条业务记录写入指定工作表行。
+ *
+ * @param sheet SXSSF 流式工作表
+ * @param rowIndex Excel 零基行号
+ * @param dataIndex 当前记录在全部明细中的零基序号
+ * @param item 当前业务记录
+ * @param context 模板初始化产生的共享上下文,允许为空
+ * @throws Exception 数据转换或行写入失败时抛出
+ */
+ void write(SXSSFSheet sheet, int rowIndex, long dataIndex, T item, C context) throws Exception;
+ }
+
+ /**
+ * 明细写完后的尾部写入回调,可用于合计、签章或备注行。
+ *
+ * @param 模板共享上下文类型
+ */
+ @FunctionalInterface
+ public interface FooterWriter {
+
+ /**
+ * 从预留尾部区域的第一行开始写入业务内容。
+ *
+ * @param sheet SXSSF 流式工作表
+ * @param firstFooterRowIndex 尾部区域第一行的 Excel 零基行号
+ * @param processedTotal 已成功写入的明细总数
+ * @param context 模板初始化产生的共享上下文,允许为空
+ * @throws Exception 尾部内容写入失败时抛出
+ */
+ void write(SXSSFSheet sheet, int firstFooterRowIndex, long processedTotal, C context) throws Exception;
+ }
+
+ /**
+ * 每批数据完整写入后的进度通知回调。
+ */
+ @FunctionalInterface
+ public interface ProgressListener {
+
+ /**
+ * 报告已完成写入的明细数量。
+ *
+ * @param processedTotal 已完成写入的明细总数
+ * @param expectedTotal 导出前预统计的明细总数
+ * @throws Exception 进度上报失败且需要终止导出时抛出
+ */
+ void onBatchCompleted(long processedTotal, long expectedTotal) throws Exception;
+ }
+
+ /**
+ * 完整 XLSX 临时文件的消费回调,可用于上传文件中心或移动到业务目录。
+ *
+ * @param 文件消费结果类型
+ */
+ @FunctionalInterface
+ public interface FileConsumer {
+
+ /**
+ * 在临时文件仍然存在时消费完整 XLSX 文件。
+ *
+ * @param file 临时 XLSX 文件,只在回调执行期间有效,不得异步持有
+ * @return 文件上传地址等业务结果
+ * @throws Exception 文件读取、上传或移动失败时抛出
+ */
+ R consume(Path file) throws Exception;
+ }
+
+ /**
+ * 单次分批流式导出的链式配置器。
+ *
+ * 配置器是有状态且非线程安全的,应在每次导出时新建并仅由当前线程使用。
+ *
+ * @param 分页查询返回的业务行类型
+ * @param 模板共享上下文类型
+ * @param 完整文件消费结果类型
+ */
+ public static final class Builder {
+
+ /** 导出前按相同筛选条件统计的明细总数。 */
+ private final long expectedTotal;
+
+ /** 单次分页查询大小,同时作为 SXSSF 内存行窗口大小。 */
+ private int batchSize = DEFAULT_BATCH_SIZE;
+
+ /** 明细开始写入的 Excel 零基行号。 */
+ private int dataStartRowIndex;
+
+ /** 明细之后为合计、备注等内容预留的行数。 */
+ private int reservedTailRowCount;
+
+ /** 目标工作表的零基下标。 */
+ private int sheetIndex;
+
+ /** 最终 XLSX 临时文件的文件名前缀。 */
+ private String temporaryFilePrefix = DEFAULT_TEMPORARY_FILE_PREFIX;
+
+ /** 是否压缩 SXSSF 生成的工作表临时 XML 文件,以磁盘计算换取较低临时空间占用。 */
+ private boolean compressTemporaryFiles = true;
+
+ /** 是否使用共享字符串表;默认关闭以避免大量唯一文本占用堆内存。 */
+ private boolean useSharedStringsTable;
+
+ /** 业务提供的模板输入流工厂。 */
+ private TemplateSupplier templateSupplier;
+
+ /** 业务提供的模板初始化回调。 */
+ private TemplateInitializer templateInitializer;
+
+ /** 业务提供的分页查询回调。 */
+ private PageFetcher pageFetcher;
+
+ /** 业务提供的逐行写入回调。 */
+ private RowWriter rowWriter;
+
+ /** 可选的业务尾部写入回调。 */
+ private FooterWriter footerWriter;
+
+ /** 可选的批次进度通知回调。 */
+ private ProgressListener progressListener;
+
+ /** 业务提供的完整文件消费回调。 */
+ private FileConsumer fileConsumer;
+
+ /**
+ * 创建单次导出配置器。
+ *
+ * @param expectedTotal 导出前预统计的明细总数
+ */
+ private Builder(long expectedTotal) {
+ this.expectedTotal = expectedTotal;
+ }
+
+ /**
+ * 设置分页大小和 SXSSF 内存行窗口大小。
+ *
+ * @param batchSize 正整数批大小
+ * @return 当前配置器
+ */
+ public Builder batchSize(int batchSize) {
+ this.batchSize = batchSize;
+ return this;
+ }
+
+ /**
+ * 设置明细开始写入的 Excel 零基行号。
+ *
+ * @param dataStartRowIndex 非负的零基行号
+ * @return 当前配置器
+ */
+ public Builder dataStartRowIndex(int dataStartRowIndex) {
+ this.dataStartRowIndex = dataStartRowIndex;
+ return this;
+ }
+
+ /**
+ * 设置明细之后必须预留的尾部行数。
+ *
+ * @param reservedTailRowCount 合计、备注等尾部内容占用的非负行数
+ * @return 当前配置器
+ */
+ public Builder reservedTailRowCount(int reservedTailRowCount) {
+ this.reservedTailRowCount = reservedTailRowCount;
+ return this;
+ }
+
+ /**
+ * 设置目标工作表下标。
+ *
+ * @param sheetIndex 非负的工作表零基下标
+ * @return 当前配置器
+ */
+ public Builder sheetIndex(int sheetIndex) {
+ this.sheetIndex = sheetIndex;
+ return this;
+ }
+
+ /**
+ * 设置最终 XLSX 临时文件的文件名前缀。
+ *
+ * @param temporaryFilePrefix 至少三个字符的文件名前缀
+ * @return 当前配置器
+ */
+ public Builder temporaryFilePrefix(String temporaryFilePrefix) {
+ this.temporaryFilePrefix = temporaryFilePrefix;
+ return this;
+ }
+
+ /**
+ * 设置是否压缩 SXSSF 的临时 XML 文件。
+ *
+ * @param compressTemporaryFiles 是否启用临时 XML 压缩
+ * @return 当前配置器
+ */
+ public Builder compressTemporaryFiles(boolean compressTemporaryFiles) {
+ this.compressTemporaryFiles = compressTemporaryFiles;
+ return this;
+ }
+
+ /**
+ * 设置是否启用共享字符串表。
+ *
+ * @param useSharedStringsTable 是否把字符串集中写入共享表
+ * @return 当前配置器
+ */
+ public Builder useSharedStringsTable(boolean useSharedStringsTable) {
+ this.useSharedStringsTable = useSharedStringsTable;
+ return this;
+ }
+
+ /**
+ * 设置模板输入流工厂。
+ *
+ * @param templateSupplier 每次执行时打开一个新模板流的回调
+ * @return 当前配置器
+ */
+ public Builder templateSupplier(TemplateSupplier templateSupplier) {
+ this.templateSupplier = templateSupplier;
+ return this;
+ }
+
+ /**
+ * 设置模板初始化回调。
+ *
+ * @param templateInitializer 模板样式读取和占位内容清理回调
+ * @return 当前配置器
+ */
+ public Builder templateInitializer(TemplateInitializer templateInitializer) {
+ this.templateInitializer = templateInitializer;
+ return this;
+ }
+
+ /**
+ * 设置分页查询回调。
+ *
+ * @param pageFetcher 按页码和批大小查询业务数据的回调
+ * @return 当前配置器
+ */
+ public Builder pageFetcher(PageFetcher pageFetcher) {
+ this.pageFetcher = pageFetcher;
+ return this;
+ }
+
+ /**
+ * 设置逐行写入回调。
+ *
+ * @param rowWriter 将单条业务数据写入工作表的回调
+ * @return 当前配置器
+ */
+ public Builder rowWriter(RowWriter rowWriter) {
+ this.rowWriter = rowWriter;
+ return this;
+ }
+
+ /**
+ * 设置可选的尾部写入回调。
+ *
+ * @param footerWriter 明细写完后写入合计、备注等内容的回调
+ * @return 当前配置器
+ */
+ public Builder footerWriter(FooterWriter footerWriter) {
+ this.footerWriter = footerWriter;
+ return this;
+ }
+
+ /**
+ * 设置可选的批次进度通知回调。
+ *
+ * @param progressListener 每批数据完整写入后的通知回调
+ * @return 当前配置器
+ */
+ public Builder progressListener(ProgressListener progressListener) {
+ this.progressListener = progressListener;
+ return this;
+ }
+
+ /**
+ * 设置最终 XLSX 文件消费回调。
+ *
+ * @param fileConsumer 上传、移动或读取最终临时文件的回调
+ * @return 当前配置器
+ */
+ public Builder fileConsumer(FileConsumer fileConsumer) {
+ this.fileConsumer = fileConsumer;
+ return this;
+ }
+
+ /**
+ * 执行分批查询、流式写入和最终文件消费。
+ *
+ * @return 文件消费回调返回的业务结果
+ * @throws Exception 模板读取、查询、写入、进度上报或文件消费失败时抛出
+ */
+ public R execute() throws Exception {
+ validate();
+ Path outputPath = null;
+ try {
+ outputPath = Files.createTempFile(temporaryFilePrefix, ".xlsx");
+ writeWorkbook(outputPath);
+ return fileConsumer.consume(outputPath);
+ }
+ finally {
+ deleteTemporaryFile(outputPath);
+ }
+ }
+
+ /**
+ * 校验所有基础参数和必需回调,避免进入写入流程后才暴露配置错误。
+ */
+ private void validate() {
+ validatePositive(expectedTotal, "导出总数必须大于零");
+ validatePositive(batchSize, "导出批大小必须大于零");
+ if (dataStartRowIndex < 0) {
+ throw new IllegalArgumentException("导出明细起始行不得小于零");
+ }
+ if (reservedTailRowCount < 0) {
+ throw new IllegalArgumentException("导出尾部预留行数不得小于零");
+ }
+ if (sheetIndex < 0) {
+ throw new IllegalArgumentException("导出工作表下标不得小于零");
+ }
+ if (temporaryFilePrefix == null || temporaryFilePrefix.length() < 3) {
+ throw new IllegalArgumentException("导出临时文件名前缀不得少于三个字符");
+ }
+ int maxDetailRowCount = calculateMaxDetailRowCount(dataStartRowIndex, reservedTailRowCount);
+ if (expectedTotal > maxDetailRowCount) {
+ throw new IllegalArgumentException("导出明细数超过当前工作表可容纳的 " + maxDetailRowCount + " 条");
+ }
+ Objects.requireNonNull(templateSupplier, "导出模板输入流工厂不能为空");
+ Objects.requireNonNull(templateInitializer, "导出模板初始化回调不能为空");
+ Objects.requireNonNull(pageFetcher, "导出分页查询回调不能为空");
+ Objects.requireNonNull(rowWriter, "导出逐行写入回调不能为空");
+ Objects.requireNonNull(fileConsumer, "导出文件消费回调不能为空");
+ }
+
+ /**
+ * 创建并写出 SXSSF 工作簿,同时保证其工作表临时 XML 被释放。
+ *
+ * @param outputPath 最终 XLSX 临时文件路径
+ * @throws Exception 模板、查询或写入任一环节失败时抛出
+ */
+ private void writeWorkbook(Path outputPath) throws Exception {
+ try (InputStream template = Objects.requireNonNull(templateSupplier.open(), "导出模板输入流不能为空");
+ XSSFWorkbook templateWorkbook = new XSSFWorkbook(template)) {
+ C context = templateInitializer.initialize(templateWorkbook);
+ SXSSFWorkbook workbook = new SXSSFWorkbook(templateWorkbook, batchSize, compressTemporaryFiles,
+ useSharedStringsTable);
+ workbook.setShouldCalculateSheetDimensions(true);
+ try (workbook; OutputStream output = Files.newOutputStream(outputPath)) {
+ writeRows(workbook.getSheetAt(sheetIndex), context);
+ workbook.write(output);
+ }
+ finally {
+ // close 关闭压缩包句柄,dispose 负责删除 SXSSF 为各工作表创建的临时 XML。
+ workbook.dispose();
+ }
+ }
+ }
+
+ /**
+ * 按页读取并逐行写入数据,最后校验实际数量与预统计数量一致。
+ *
+ * @param sheet 目标 SXSSF 工作表
+ * @param context 模板初始化产生的共享上下文
+ * @throws Exception 查询、行写入或尾部写入失败时抛出
+ */
+ private void writeRows(SXSSFSheet sheet, C context) throws Exception {
+ long processedTotal = 0;
+ long pageNumber = 1;
+ while (true) {
+ List rows = pageFetcher.fetch(pageNumber++, batchSize);
+ if (rows == null || rows.isEmpty()) {
+ break;
+ }
+ if (rows.size() > batchSize) {
+ throw new IllegalStateException("导出分页查询返回数量超过配置的批大小");
+ }
+ if (processedTotal + rows.size() > expectedTotal) {
+ throw dataChangedException(processedTotal + rows.size());
+ }
+ for (T item : rows) {
+ int rowIndex = Math.toIntExact((long) dataStartRowIndex + processedTotal);
+ rowWriter.write(sheet, rowIndex, processedTotal, item, context);
+ processedTotal++;
+ }
+ if (progressListener != null) {
+ progressListener.onBatchCompleted(processedTotal, expectedTotal);
+ }
+ if (rows.size() < batchSize && processedTotal < expectedTotal) {
+ break;
+ }
+ }
+ if (processedTotal != expectedTotal) {
+ throw dataChangedException(processedTotal);
+ }
+ if (footerWriter != null) {
+ int footerRowIndex = Math.toIntExact((long) dataStartRowIndex + processedTotal);
+ footerWriter.write(sheet, footerRowIndex, processedTotal, context);
+ }
+ }
+
+ /**
+ * 创建导出期间数据变化异常,统一向业务方暴露预期数量和实际数量。
+ *
+ * @param actualTotal 当前已读取或即将写入的实际数量
+ * @return 可直接抛出的状态异常
+ */
+ private IllegalStateException dataChangedException(long actualTotal) {
+ return new IllegalStateException("导出数据在执行期间发生变化,预期 " + expectedTotal + " 条,实际 "
+ + actualTotal + " 条");
+ }
+
+ /**
+ * 删除公共模板创建的最终 XLSX 临时文件,清理失败仅记录告警,避免覆盖原始业务结果。
+ *
+ * @param outputPath 待删除的临时文件路径,允许为空
+ */
+ private void deleteTemporaryFile(Path outputPath) {
+ if (outputPath == null) {
+ return;
+ }
+ try {
+ Files.deleteIfExists(outputPath);
+ }
+ catch (Exception exception) {
+ LOGGER.warn("|批量Excel导出|清理最终临时文件失败,path:{}", outputPath, exception);
+ }
+ }
+ }
+}
diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/ExportMultipartFile.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/ExportMultipartFile.java
new file mode 100644
index 000000000..1656d02ef
--- /dev/null
+++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/ExportMultipartFile.java
@@ -0,0 +1,24 @@
+package com.bsd.pjcloud.common.excel.stream;
+
+import org.springframework.web.multipart.MultipartFile;
+import java.io.*;
+import java.nio.file.*;
+
+/** 文件中心上传适配;本地上传直接读取临时文件流,不使用测试库的 MockMultipartFile。 */
+public final class ExportMultipartFile implements MultipartFile {
+ /** 完整且已关闭的导出文件。 */
+ private final Path path;
+ /** 文件中心保存的业务文件名。 */
+ private final String fileName;
+
+ /** 绑定本次导出文件;调用方负责在上传完成后清理文件。 */
+ public ExportMultipartFile(Path path, String fileName) { this.path = path; this.fileName = fileName; }
+ @Override public String getName() { return "file"; }
+ @Override public String getOriginalFilename() { return fileName; }
+ @Override public String getContentType() { return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; }
+ @Override public boolean isEmpty() { return getSize() == 0; }
+ @Override public long getSize() { try { return Files.size(path); } catch (IOException exception) { throw new UncheckedIOException(exception); } }
+ @Override public byte[] getBytes() throws IOException { return Files.readAllBytes(path); }
+ @Override public InputStream getInputStream() throws IOException { return Files.newInputStream(path); }
+ @Override public void transferTo(File destination) throws IOException { Files.copy(path, destination.toPath(), StandardCopyOption.REPLACE_EXISTING); }
+}
diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/TemplateBatchWorkbook.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/TemplateBatchWorkbook.java
new file mode 100644
index 000000000..a1e939ed5
--- /dev/null
+++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/TemplateBatchWorkbook.java
@@ -0,0 +1,182 @@
+package com.bsd.pjcloud.common.excel.stream;
+
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.ss.util.CellRangeAddress;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+
+import java.io.InputStream;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.*;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** 保留既有占位符模板布局的分批工作簿;仅缓存模板行及一个数据批次。 */
+public final class TemplateBatchWorkbook {
+ /** 同时识别列表占位符与标题、合计占位符。 */
+ private static final Pattern TOKEN = Pattern.compile("\\{(\\.?[^{}]+)}");
+
+ private TemplateBatchWorkbook() { }
+
+ /**
+ * 按原模板流式生成完整文件,文件消费者返回后自动清理临时文件。
+ * values 可由逐行转换器累计合计;只有写入尾部时才读取最终合计。
+ */
+ public static R write(BatchExcelExportTemplate.TemplateSupplier template, long total, int batchSize,
+ Map values, BatchExcelExportTemplate.PageFetcher fetcher,
+ Function> converter,
+ BatchExcelExportTemplate.ProgressListener progress,
+ BatchExcelExportTemplate.FileConsumer consumer,
+ Consumer customizer) throws Exception {
+ int start;
+ int tail;
+ // 先读取很小的模板确定物理行数上限,不能把大工作簿加载进 XSSF。
+ try (InputStream input = template.open(); XSSFWorkbook workbook = new XSSFWorkbook(input)) {
+ customizer.accept(workbook);
+ start = findDetailRow(workbook.getSheetAt(0));
+ tail = workbook.getSheetAt(0).getLastRowNum() - start;
+ }
+ return BatchExcelExportTemplate.builder(total)
+ .batchSize(batchSize).dataStartRowIndex(start).reservedTailRowCount(tail)
+ .templateSupplier(template)
+ .templateInitializer(workbook -> {
+ customizer.accept(workbook);
+ return prepare(workbook, values);
+ })
+ .pageFetcher(fetcher)
+ .rowWriter((sheet, row, index, item, layout) -> {
+ Map data = converter.apply(item);
+ data.putIfAbsent("lineNumber", index + 1);
+ copyRow(sheet.createRow(row), layout.detail, data, layout);
+ })
+ .footerWriter((sheet, row, count, layout) -> {
+ for (int i = 0; i < layout.footer.size(); i++) {
+ copyRow(sheet.createRow(row + i), layout.footer.get(i), values, layout);
+ }
+ for (CellRangeAddress merge : layout.tailMerges) {
+ int delta = row - layout.start - 1;
+ sheet.addMergedRegion(new CellRangeAddress(merge.getFirstRow() + delta,
+ merge.getLastRow() + delta, merge.getFirstColumn(), merge.getLastColumn()));
+ }
+ }).progressListener(progress).fileConsumer(consumer).execute();
+ }
+
+ /** 从列表占位符定位明细行,拒绝误用无明细模板。 */
+ private static int findDetailRow(Sheet sheet) {
+ for (Row row : sheet) {
+ for (Cell cell : row) {
+ if (cell.getCellType() == CellType.STRING && cell.getStringCellValue().contains("{.")) {
+ return row.getRowNum();
+ }
+ }
+ }
+ throw new IllegalArgumentException("导出模板缺少明细占位行");
+ }
+
+ /** 缓存原始样式和合计布局,移除占位区域后交给 SXSSF 顺序写入。 */
+ private static Layout prepare(XSSFWorkbook workbook, Map values) {
+ Sheet sheet = workbook.getSheetAt(0);
+ int start = findDetailRow(sheet);
+ Layout layout = new Layout(start, snapshot(sheet.getRow(start)), new ArrayList<>(), new ArrayList<>(), new HashMap<>());
+ for (int i = start + 1; i <= sheet.getLastRowNum(); i++) layout.footer.add(snapshot(sheet.getRow(i)));
+ for (int i = sheet.getNumMergedRegions() - 1; i >= 0; i--) {
+ CellRangeAddress merge = sheet.getMergedRegion(i);
+ if (merge.getFirstRow() > start) {
+ layout.tailMerges.add(merge);
+ sheet.removeMergedRegion(i);
+ } else if (merge.getLastRow() >= start) {
+ throw new IllegalArgumentException("导出模板明细行不支持跨行合并");
+ }
+ }
+ for (int i = sheet.getLastRowNum(); i >= start; i--) {
+ Row row = sheet.getRow(i);
+ if (row != null) sheet.removeRow(row);
+ }
+ for (Row row : sheet) {
+ for (Cell cell : row) {
+ if (cell.getCellType() == CellType.STRING) {
+ putValue(cell, resolve(cell.getStringCellValue(), values), layout);
+ }
+ }
+ }
+ return layout;
+ }
+
+ /** 只保存模板的单元格值、样式及行高,不复制任何业务数据。 */
+ private static RowPattern snapshot(Row row) {
+ List cells = new ArrayList<>();
+ if (row != null) {
+ for (Cell cell : row) {
+ Object value = switch (cell.getCellType()) {
+ case STRING -> cell.getStringCellValue();
+ case NUMERIC -> cell.getNumericCellValue();
+ case BOOLEAN -> cell.getBooleanCellValue();
+ case FORMULA -> cell.getCellFormula();
+ default -> null;
+ };
+ cells.add(new CellPattern(cell.getColumnIndex(), cell.getCellStyle(), cell.getCellType(), value));
+ }
+ }
+ return new RowPattern(row == null ? (short) -1 : row.getHeight(), cells);
+ }
+
+ /** 逐行复用模板样式,避免创建与数据量成正比的 CellStyle。 */
+ private static void copyRow(Row row, RowPattern pattern, Map values, Layout layout) {
+ row.setHeight(pattern.height);
+ for (CellPattern source : pattern.cells) {
+ Cell cell = row.createCell(source.column);
+ cell.setCellStyle(source.style);
+ if (source.type == CellType.FORMULA) cell.setCellFormula((String) source.value);
+ else putValue(cell, source.value instanceof String text ? resolve(text, values) : source.value, layout);
+ }
+ }
+
+ /** 完整占位符保留数值类型;混合文本占位符沿用字符串结果。 */
+ private static Object resolve(String text, Map values) {
+ Matcher matcher = TOKEN.matcher(text);
+ if (matcher.matches()) return values.get(matcher.group(1).replaceFirst("^\\.", ""));
+ matcher.reset();
+ StringBuilder result = new StringBuilder();
+ while (matcher.find()) {
+ Object value = values.get(matcher.group(1).replaceFirst("^\\.", ""));
+ matcher.appendReplacement(result, Matcher.quoteReplacement(value == null ? "" : value.toString()));
+ }
+ return matcher.appendTail(result).toString();
+ }
+
+ /** 按 EasyExcel 模板填充的默认类型写入,日期使用相同的日期格式。 */
+ private static void putValue(Cell cell, Object value, Layout layout) {
+ if (value == null) cell.setBlank();
+ else if (value instanceof Number number) cell.setCellValue(number.doubleValue());
+ else if (value instanceof Boolean bool) cell.setCellValue(bool);
+ else if (value instanceof LocalDateTime date) {
+ cell.setCellValue(date);
+ applyDateStyle(cell, layout);
+ } else if (value instanceof LocalDate date) {
+ cell.setCellValue(date);
+ applyDateStyle(cell, layout);
+ } else cell.setCellValue(value.toString());
+ }
+
+ /** 日期样式按模板样式索引缓存,与原 EasyExcel 的默认显示格式一致。 */
+ private static void applyDateStyle(Cell cell, Layout layout) {
+ short original = cell.getCellStyle().getIndex();
+ CellStyle style = layout.dateStyles.computeIfAbsent(original, key -> {
+ CellStyle created = cell.getSheet().getWorkbook().createCellStyle();
+ created.cloneStyleFrom(cell.getCellStyle());
+ created.setDataFormat(cell.getSheet().getWorkbook().createDataFormat().getFormat("yyyy-MM-dd HH:mm:ss"));
+ return created;
+ });
+ cell.setCellStyle(style);
+ }
+
+ /** 模板单元格快照;样式直接属于当前工作簿。 */
+ private record CellPattern(int column, CellStyle style, CellType type, Object value) { }
+ /** 模板行快照,包括原行高。 */
+ private record RowPattern(short height, List cells) { }
+ /** 单个工作簿的模板上下文,生命周期仅覆盖一次导出。 */
+ private record Layout(int start, RowPattern detail, List footer,
+ List tailMerges, Map dateStyles) { }
+}
diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/style/ExcelExportStyleHandler.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/style/ExcelExportStyleHandler.java
new file mode 100644
index 000000000..51632c2af
--- /dev/null
+++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/style/ExcelExportStyleHandler.java
@@ -0,0 +1,193 @@
+package com.bsd.pjcloud.common.excel.style;
+
+import com.alibaba.excel.constant.OrderConstant;
+import com.alibaba.excel.write.handler.CellWriteHandler;
+import com.alibaba.excel.write.handler.SheetWriteHandler;
+import com.alibaba.excel.write.handler.WorkbookWriteHandler;
+import com.alibaba.excel.write.handler.context.CellWriteHandlerContext;
+import com.alibaba.excel.write.metadata.holder.WriteSheetHolder;
+import com.alibaba.excel.write.metadata.holder.WriteWorkbookHolder;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.ss.util.CellRangeAddress;
+import org.apache.poi.xssf.usermodel.XSSFCellStyle;
+import org.apache.poi.xssf.usermodel.XSSFColor;
+import org.apache.poi.xssf.usermodel.XSSFFont;
+
+import java.util.HashMap;
+import java.util.IdentityHashMap;
+import java.util.Map;
+
+/**
+ * 可选的 XLSX 样式处理器,每次导出创建一个实例并注册在 ExcelWriterBuilder 上。
+ * 构造时固定 token;按原单元格样式和行类型缓存样式,字体仅创建两份。
+ * 不扫描明细、不按行创建 POI 样式,保留转换器产生的数据格式与单元格类型。
+ */
+public class ExcelExportStyleHandler implements CellWriteHandler, SheetWriteHandler, WorkbookWriteHandler {
+
+ private final Appearance header;
+ private final Appearance body;
+ private final boolean striped;
+ private final byte[] alternateRowColor;
+ private final BorderStyle borderStyle;
+ private final byte[] borderColor;
+ private final int columnWidth;
+ private final Map columnWidths;
+ private final boolean freezeHeader;
+ private final boolean autoFilter;
+ private final boolean showGridLines;
+ private final Map styles = new HashMap<>();
+ private final Map layouts = new IdentityHashMap<>();
+ private XSSFFont headerFont;
+ private XSSFFont bodyFont;
+
+ public ExcelExportStyleHandler(ExcelStyleTokens tokens) {
+ this(tokens, Map.of());
+ }
+
+ public ExcelExportStyleHandler(ExcelStyleTokens tokens, Map defaultColumnWidths) {
+ header = Appearance.of(tokens.getHeader());
+ body = Appearance.of(tokens.getBody());
+ striped = tokens.isStriped();
+ alternateRowColor = rgb(tokens.getAlternateRowColor());
+ borderStyle = tokens.getBorderStyle();
+ borderColor = rgb(tokens.getBorderColor());
+ columnWidth = tokens.getColumnWidth();
+ Map widths = new HashMap<>(defaultColumnWidths);
+ widths.putAll(tokens.getColumnWidths());
+ columnWidths = Map.copyOf(widths);
+ freezeHeader = tokens.isFreezeHeader();
+ autoFilter = tokens.isAutoFilter();
+ showGridLines = tokens.isShowGridLines();
+ }
+
+ @Override
+ public int order() {
+ // 在 EasyExcel 样式及 @ColumnWidth 应用之后调整,保留原数据格式。
+ return OrderConstant.FILL_STYLE + 1;
+ }
+
+ @Override
+ public void afterWorkbookCreate(WriteWorkbookHolder holder) {
+ headerFont = createFont(holder.getWorkbook(), header);
+ bodyFont = createFont(holder.getWorkbook(), body);
+ }
+
+ @Override
+ public void afterSheetCreate(WriteWorkbookHolder workbook, WriteSheetHolder holder) {
+ Sheet sheet = holder.getSheet();
+ sheet.setDisplayGridlines(showGridLines);
+ sheet.setDefaultColumnWidth(columnWidth);
+ sheet.setDefaultRowHeightInPoints(body.rowHeight());
+ layouts.put(sheet, new SheetLayout());
+ }
+
+ @Override
+ public void afterCellDispose(CellWriteHandlerContext context) {
+ if (context.getHead() == null) return;
+ Cell cell = context.getCell();
+ Sheet sheet = cell.getSheet();
+ SheetLayout layout = layouts.get(sheet);
+ boolean isHeader = Boolean.TRUE.equals(context.getHead());
+ if (isHeader) {
+ layout.lastHeaderRow = Math.max(layout.lastHeaderRow, cell.getRowIndex());
+ String field = context.getHeadData() == null ? null : context.getHeadData().getFieldName();
+ double width = field == null ? columnWidth : columnWidths.getOrDefault(field, (double) columnWidth);
+ layout.columnWidths.put(cell.getColumnIndex(), (int) Math.round(width * 256));
+ }
+ if (layout.lastStyledRow != cell.getRowIndex()) {
+ cell.getRow().setHeightInPoints(isHeader ? header.rowHeight() : body.rowHeight());
+ layout.lastStyledRow = cell.getRowIndex();
+ }
+ layout.lastRow = Math.max(layout.lastRow, cell.getRowIndex());
+ layout.lastColumn = Math.max(layout.lastColumn, cell.getColumnIndex());
+ // relativeRowIndex 会在下一批重置,条纹必须使用工作表绝对行号。
+ int kind = 1;
+ if (isHeader) kind = 0;
+ else if (striped && (cell.getRowIndex() - layout.lastHeaderRow - 1) % 2 == 1) kind = 2;
+ CellStyle base = cell.getCellStyle();
+ StyleKey key = new StyleKey(base.getIndex(), kind);
+ int styleKind = kind;
+ cell.setCellStyle(styles.computeIfAbsent(key, ignored -> createStyle(sheet.getWorkbook(), base, styleKind)));
+ }
+
+ @Override
+ public void afterWorkbookDispose(WriteWorkbookHolder holder) {
+ // EasyExcel 在序列化文件前回调;使用记录的边界,兼容已经刷盘的 SXSSF 行。
+ try {
+ layouts.forEach((sheet, layout) -> {
+ // @ColumnWidth 在每个正文单元格回调中也会执行,因此最后统一设置列宽。
+ layout.columnWidths.forEach(sheet::setColumnWidth);
+ if (layout.lastHeaderRow < 0) return;
+ if (freezeHeader) sheet.createFreezePane(0, layout.lastHeaderRow + 1);
+ if (autoFilter) sheet.setAutoFilter(new CellRangeAddress(
+ layout.lastHeaderRow, layout.lastRow, 0, layout.lastColumn));
+ });
+ } finally {
+ styles.clear();
+ layouts.clear();
+ headerFont = null;
+ bodyFont = null;
+ }
+ }
+
+ private XSSFCellStyle createStyle(Workbook workbook, CellStyle base, int kind) {
+ XSSFCellStyle style = (XSSFCellStyle) workbook.createCellStyle();
+ style.cloneStyleFrom(base);
+ Appearance appearance = kind == 0 ? header : body;
+ style.setFont(kind == 0 ? headerFont : bodyFont);
+ style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
+ style.setFillForegroundColor(new XSSFColor(kind == 2 ? alternateRowColor : appearance.backgroundColor(), null));
+ style.setAlignment(appearance.alignment());
+ style.setVerticalAlignment(VerticalAlignment.CENTER);
+ style.setWrapText(appearance.wrapText());
+ style.setIndention((short) appearance.indent());
+ // 正文只保留水平分隔线,表头增加列分隔,避免密集网格干扰阅读。
+ style.setBorderTop(BorderStyle.NONE);
+ style.setBorderBottom(borderStyle);
+ style.setBorderLeft(kind == 0 ? borderStyle : BorderStyle.NONE);
+ style.setBorderRight(kind == 0 ? borderStyle : BorderStyle.NONE);
+ XSSFColor border = new XSSFColor(borderColor, null);
+ style.setTopBorderColor(border);
+ style.setBottomBorderColor(border);
+ style.setLeftBorderColor(border);
+ style.setRightBorderColor(border);
+ return style;
+ }
+
+ private static XSSFFont createFont(Workbook workbook, Appearance appearance) {
+ if (!(workbook.createFont() instanceof XSSFFont font)) {
+ throw new IllegalArgumentException("ExcelExportStyleHandler 仅支持 XLSX 导出");
+ }
+ font.setFontName(appearance.fontName());
+ font.setFontHeightInPoints((short) appearance.fontSize());
+ font.setBold(appearance.bold());
+ font.setColor(new XSSFColor(appearance.fontColor(), null));
+ return font;
+ }
+
+ private static byte[] rgb(String color) {
+ String value = color.startsWith("#") ? color.substring(1) : color;
+ int rgb = Integer.parseInt(value, 16);
+ return new byte[] { (byte) (rgb >> 16), (byte) (rgb >> 8), (byte) rgb };
+ }
+
+ private record StyleKey(int baseStyle, int kind) {}
+
+ private record Appearance(String fontName, int fontSize, boolean bold, byte[] fontColor,
+ byte[] backgroundColor, int rowHeight, HorizontalAlignment alignment,
+ boolean wrapText, int indent) {
+ private static Appearance of(ExcelStyleTokens.CellTokens tokens) {
+ return new Appearance(tokens.getFontName(), tokens.getFontSize(), tokens.isBold(),
+ rgb(tokens.getFontColor()), rgb(tokens.getBackgroundColor()), tokens.getRowHeight(),
+ tokens.getAlignment(), tokens.isWrapText(), tokens.getIndent());
+ }
+ }
+
+ private static class SheetLayout {
+ private final Map columnWidths = new HashMap<>();
+ private int lastHeaderRow = -1;
+ private int lastStyledRow = -1;
+ private int lastRow;
+ private int lastColumn;
+ }
+}
diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/style/ExcelStyleTokens.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/style/ExcelStyleTokens.java
new file mode 100644
index 000000000..0a97aa5ee
--- /dev/null
+++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/style/ExcelStyleTokens.java
@@ -0,0 +1,102 @@
+package com.bsd.pjcloud.common.excel.style;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.*;
+import lombok.Data;
+import org.apache.poi.ss.usermodel.BorderStyle;
+import org.apache.poi.ss.usermodel.HorizontalAlignment;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * XLSX 导出的全局默认样式,通过 excel.export-style 配置覆盖。
+ * 仅供显式接入的导出使用;表头与正文独立配置,颜色为六位 RGB。
+ */
+@Data
+public class ExcelStyleTokens {
+
+ @Valid
+ @NotNull
+ private CellTokens header = headerDefaults();
+
+ @Valid
+ @NotNull
+ private CellTokens body = new CellTokens();
+
+ /** 启用正文交替行底纹,按工作表实际行号计算,跨批次连续。 */
+ private boolean striped = true;
+
+ @NotNull
+ @Pattern(regexp = "#?[0-9a-fA-F]{6}")
+ private String alternateRowColor = "#F3F6FA";
+
+ @NotNull
+ private BorderStyle borderStyle = BorderStyle.THIN;
+
+ @NotNull
+ @Pattern(regexp = "#?[0-9a-fA-F]{6}")
+ private String borderColor = "#DCE3EC";
+
+ /** 列宽单位为 Excel 字符宽度,避免对海量明细执行自动列宽扫描。 */
+ @Min(1)
+ @Max(255)
+ private int columnWidth = 22;
+
+ /** 按 Java 字段名覆盖列宽,优先于业务导出的默认列宽。 */
+ @NotNull
+ private Map columnWidths = new LinkedHashMap<>();
+
+ private boolean freezeHeader = true;
+
+ private boolean autoFilter = true;
+
+ private boolean showGridLines = false;
+
+ private static CellTokens headerDefaults() {
+ CellTokens header = new CellTokens();
+ header.setBold(true);
+ header.setFontColor("#FFFFFF");
+ header.setBackgroundColor("#1F4E78");
+ header.setRowHeight(36);
+ header.setAlignment(HorizontalAlignment.CENTER);
+ header.setWrapText(true);
+ header.setIndent(0);
+ return header;
+ }
+
+ /** 表头或正文的语义样式 token;行高与字号的单位均为磅。 */
+ @Data
+ public static class CellTokens {
+
+ @NotBlank
+ private String fontName = "Microsoft YaHei";
+
+ @Min(1)
+ @Max(409)
+ private int fontSize = 11;
+
+ private boolean bold = false;
+
+ @NotNull
+ @Pattern(regexp = "#?[0-9a-fA-F]{6}")
+ private String fontColor = "#243247";
+
+ @NotNull
+ @Pattern(regexp = "#?[0-9a-fA-F]{6}")
+ private String backgroundColor = "#FFFFFF";
+
+ @Min(1)
+ @Max(409)
+ private int rowHeight = 26;
+
+ @NotNull
+ private HorizontalAlignment alignment = HorizontalAlignment.LEFT;
+
+ private boolean wrapText = false;
+
+ @Min(0)
+ @Max(15)
+ private int indent = 1;
+ }
+}
diff --git a/pjcloud-common/pjcloud-common-excel/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/pjcloud-common/pjcloud-common-excel/src/main/resources/META-INF/additional-spring-configuration-metadata.json
new file mode 100644
index 000000000..8dcb0fd41
--- /dev/null
+++ b/pjcloud-common/pjcloud-common-excel/src/main/resources/META-INF/additional-spring-configuration-metadata.json
@@ -0,0 +1,36 @@
+{
+ "properties": [
+ {
+ "name": "excel.export-style.header.alignment",
+ "defaultValue": "CENTER"
+ },
+ {
+ "name": "excel.export-style.header.background-color",
+ "defaultValue": "#1F4E78"
+ },
+ {
+ "name": "excel.export-style.header.bold",
+ "defaultValue": true
+ },
+ {
+ "name": "excel.export-style.header.font-color",
+ "defaultValue": "#FFFFFF"
+ },
+ {
+ "name": "excel.export-style.header.indent",
+ "defaultValue": 0
+ },
+ {
+ "name": "excel.export-style.header.row-height",
+ "defaultValue": 36
+ },
+ {
+ "name": "excel.export-style.header.wrap-text",
+ "defaultValue": true
+ },
+ {
+ "name": "excel.export-style.body.alignment",
+ "defaultValue": "LEFT"
+ }
+ ]
+}
diff --git a/pjcloud-common/pom.xml b/pjcloud-common/pom.xml
index 268b4ca67..bef0fd924 100644
--- a/pjcloud-common/pom.xml
+++ b/pjcloud-common/pom.xml
@@ -39,6 +39,7 @@
pjcloud-common-data
pjcloud-common-datasource
pjcloud-common-excel
+ pjcloud-common-async-task
pjcloud-common-encrypt-api
pjcloud-common-feign
pjcloud-common-gateway
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderTypeEnum.java
index fd2380239..2626deb20 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderTypeEnum.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderTypeEnum.java
@@ -18,7 +18,7 @@ public enum HardOrderTypeEnum {
FOREIGN_TICKET("3", "外来人员"),
STRIDE_DEPT("4", "跨单位"),
ZY("5", "正元公务餐"),
- LB("6", "宁波银行(美好生活)"),
+ THIRD_APP("6", "第三方app"),
FARMER("7", "共富商城(助农)"),
TOC_VISITOR("8", "toc访客购买(不记名用户)"),
XFJ("9", "消费机支付"),
@@ -32,7 +32,7 @@ public enum HardOrderTypeEnum {
return Arrays.stream(values()).filter(v -> ObjectUtil.equals(type, v.getType())).findFirst().get();
}
- public static final List refundList = List.of(BELOW, RESERVE, FOREIGN_TICKET, ZY, LB, FARMER);
+ public static final List refundList = List.of(BELOW, RESERVE, FOREIGN_TICKET, ZY, THIRD_APP, FARMER);
public static final List payList = List.of(BELOW, RESERVE);
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java
index ba0af61a5..e16a2ba11 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java
@@ -55,6 +55,9 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeDeptMc
private List orderPayTypeCodes;
+ @Schema(description = "是否有额外支付:true 有,false 无,null 不限制")
+ private Boolean hasExtraPrice;
+
private List hardTypeCodes;
private List refundStatusCodes;
@@ -69,6 +72,9 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeDeptMc
*/
private String afterSaleNo;
+ /** 第三方底单退款进度:NONE / PARTIAL / FULL / PROCESSING。 */
+ private String thirdRefundState;
+
/**
* 0 yyyy-MM-dd
* 1 yyyy-MM
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardThirdOrderRefundDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardThirdOrderRefundDto.java
new file mode 100644
index 000000000..225122339
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardThirdOrderRefundDto.java
@@ -0,0 +1,30 @@
+package com.bsd.pjcloud.hard.api.dto.order;
+
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/** 第三方应用底单按金额退款;金额单位为分。 */
+@Data
+public class HardThirdOrderRefundDto {
+ @NotNull(message = "订单编号不能为空")
+ private Long orderId;
+
+ @NotNull(message = "退款金额不能为空")
+ @Min(value = 1, message = "退款金额至少为1分")
+ private Integer refundPrice;
+
+ @NotBlank(message = "退款请求编号不能为空")
+ @Pattern(regexp = "[A-Za-z0-9_-]{16,40}", message = "退款请求编号格式不正确")
+ private String requestNo;
+
+ @NotBlank(message = "退款原因不能为空")
+ @Size(max = 200, message = "退款原因不能超过200字")
+ private String applyReason;
+
+ @Size(max = 255, message = "补充说明不能超过255字")
+ private String applyDescription;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderStatisticsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderStatisticsVo.java
new file mode 100644
index 000000000..7e9c87c25
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderStatisticsVo.java
@@ -0,0 +1,67 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+/** 订单列表实时统计。金额均为整数分,以字符串返回,避免累计金额溢出或前端精度丢失。 */
+@Data
+public class HardOrderStatisticsVo {
+
+ private Totals summary = new Totals();
+ private List daily = new ArrayList<>();
+ private List payTypes = new ArrayList<>();
+ private Totals withoutPayTime = new Totals();
+ private LocalDateTime generatedAt;
+
+ @Data
+ public static class Totals {
+ @JsonSerialize(using = ToStringSerializer.class)
+ private Long orderCount = 0L;
+ @JsonSerialize(using = ToStringSerializer.class)
+ private BigDecimal totalPrice = BigDecimal.ZERO;
+ @JsonSerialize(using = ToStringSerializer.class)
+ private BigDecimal extraPrice = BigDecimal.ZERO;
+ @JsonSerialize(using = ToStringSerializer.class)
+ private BigDecimal couponPrice = BigDecimal.ZERO;
+ @JsonSerialize(using = ToStringSerializer.class)
+ private BigDecimal payPrice = BigDecimal.ZERO;
+ @JsonSerialize(using = ToStringSerializer.class)
+ private BigDecimal refundPrice = BigDecimal.ZERO;
+
+ @JsonSerialize(using = ToStringSerializer.class)
+ public BigDecimal getNetPrice() {
+ return payPrice.subtract(refundPrice);
+ }
+ }
+
+ @Data
+ @EqualsAndHashCode(callSuper = true)
+ public static class Daily extends Totals {
+ private LocalDate date;
+ }
+
+ @Data
+ @EqualsAndHashCode(callSuper = true)
+ public static class PayType extends Totals {
+ private String payType;
+ }
+
+ @Data
+ @EqualsAndHashCode(callSuper = true)
+ public static class Merchant extends Totals {
+ private Long merchantId;
+ private String merchantName;
+ }
+
+ public record MerchantPage(List records, int current, int size, boolean hasMore,
+ LocalDateTime generatedAt) {
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardThirdOrderDetailVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardThirdOrderDetailVo.java
new file mode 100644
index 000000000..b6ec3c96e
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardThirdOrderDetailVo.java
@@ -0,0 +1,14 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
+import lombok.Data;
+
+import java.util.List;
+
+/** 第三方支付底单及其完整商品、售后记录。 */
+@Data
+public class HardThirdOrderDetailVo {
+ private HardOrderVo order;
+ private List products;
+ private List refunds;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardThirdOrderExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardThirdOrderExcelVo.java
new file mode 100644
index 000000000..355b7665f
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardThirdOrderExcelVo.java
@@ -0,0 +1,77 @@
+package com.bsd.pjcloud.hard.api.vo.order.excel;
+
+import com.alibaba.excel.annotation.ExcelProperty;
+import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Arrays;
+
+/** 第三方底单专用导出;长编号按文本输出,金额单位为元。 */
+@Data
+public class HardThirdOrderExcelVo {
+ @ExcelProperty("第三方订单号")
+ private String thirdOrderNo;
+ @ExcelProperty("平台底单号")
+ private String platformOrderId;
+ @ExcelProperty("支付用户")
+ private String username;
+ @ExcelProperty("手机号")
+ private String mobile;
+ @ExcelProperty("用户部门")
+ private String userDeptName;
+ @ExcelProperty("支付商户")
+ private String merchantName;
+ @ExcelProperty("钱包编号")
+ private String walletId;
+ @ExcelProperty("商品描述")
+ private String productDesc;
+ @ExcelProperty("商品总额(元)")
+ private BigDecimal totalPrice;
+ @ExcelProperty("支付差额(元)")
+ private BigDecimal extraPrice;
+ @ExcelProperty("支付金额(元)")
+ private BigDecimal payPrice;
+ @ExcelProperty("累计退款(元)")
+ private BigDecimal refundPrice;
+ @ExcelProperty("剩余可退(元)")
+ private BigDecimal remainingPrice;
+ @ExcelProperty("底单状态")
+ private String status;
+ @ExcelProperty("创建时间")
+ private String createTime;
+ @ExcelProperty("支付时间")
+ private String payTime;
+ @ExcelProperty("最新退款时间")
+ private String refundTime;
+
+ public static HardThirdOrderExcelVo from(HardOrderVo order) {
+ HardThirdOrderExcelVo row = new HardThirdOrderExcelVo();
+ row.setThirdOrderNo(order.getOrderNo());
+ row.setPlatformOrderId(String.valueOf(order.getId()));
+ row.setUsername(order.getUsername());
+ row.setMobile(order.getUserMobile());
+ row.setUserDeptName(order.getUserDeptName());
+ row.setMerchantName(order.getMerchantName());
+ row.setWalletId(order.getWalletId() == null ? "" : String.valueOf(order.getWalletId()));
+ row.setProductDesc(order.getProductDesc());
+ row.setTotalPrice(yuan(order.getTotalPrice()));
+ row.setExtraPrice(yuan(order.getExtraPrice()));
+ row.setPayPrice(yuan(order.getPayPrice()));
+ row.setRefundPrice(yuan(order.getRefundPrice()));
+ row.setRemainingPrice(order.getPayTime() == null ? BigDecimal.ZERO : row.getPayPrice().subtract(row.getRefundPrice()).max(BigDecimal.ZERO));
+ row.setStatus(Arrays.stream(HardOrderStatusEnum.values()).filter(item -> item.getType().equals(order.getOrderStatus()))
+ .map(HardOrderStatusEnum::getDescription).findFirst().orElse(order.getOrderStatus()));
+ if (row.getRefundPrice().signum() > 0) row.setStatus(row.getRefundPrice().compareTo(row.getPayPrice()) >= 0 ? "已全额退款" : "已部分退款");
+ if ("10".equals(order.getRefundStatus())) row.setStatus("退款处理中");
+ row.setCreateTime(order.getCreateTime() == null ? "" : order.getCreateTime().toString().replace('T', ' '));
+ row.setPayTime(order.getPayTime() == null ? "" : order.getPayTime().toString().replace('T', ' '));
+ row.setRefundTime(order.getRefundTime() == null ? "" : order.getRefundTime().toString().replace('T', ' '));
+ return row;
+ }
+
+ private static BigDecimal yuan(Integer cents) {
+ return BigDecimal.valueOf(cents == null ? 0 : cents).movePointLeft(2);
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/pom.xml b/pjcloud-hardware/pjcloud-hardware-biz/pom.xml
index a69771160..b6389104e 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/pom.xml
+++ b/pjcloud-hardware/pjcloud-hardware-biz/pom.xml
@@ -13,6 +13,16 @@
pjcloud-hardware-biz
+
+
+ com.bsd
+ pjcloud-common-async-task
+
+
+ com.h2database
+ h2
+ test
+
com.bsd
pjcloud-common-encrypt-api
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/config/LbCallbackConfig.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/config/ThirdAppCallbackConfig.java
similarity index 74%
rename from pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/config/LbCallbackConfig.java
rename to pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/config/ThirdAppCallbackConfig.java
index 383a73cf4..b9a742edb 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/config/LbCallbackConfig.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/config/ThirdAppCallbackConfig.java
@@ -7,11 +7,17 @@ import org.springframework.stereotype.Component;
@Data
@ConfigurationProperties(prefix = "pay-callback")
@Component
-public class LbCallbackConfig {
+public class ThirdAppCallbackConfig {
/**
* 宁波银行(美好生活)商户id
*/
private Long lbMchId;
+
+ /**
+ * 农发农产品商户id
+ */
+ private Long nffcpMchId;
+
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
index 927fcd752..ced3262ff 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
@@ -28,7 +28,6 @@ import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.dto.app.HardAppCreateOrderScancode;
-import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto;
import com.bsd.pjcloud.hard.api.dto.hard.HardOfflinePayDto;
import com.bsd.pjcloud.hard.api.dto.order.HardDoorManualSettleDto;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
@@ -38,11 +37,14 @@ import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateFarmerOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateOrderDto;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderStatisticsVo;
+import com.bsd.pjcloud.hard.service.order.HardOrderQuerySupport;
+import com.bsd.pjcloud.hard.service.order.HardOrderStatisticsService;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardOrderExcelVo;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
-import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.hard.service.HardOrderService;
import com.bsd.pjcloud.hard.service.HardOrderSyncService;
+import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
@@ -77,14 +79,19 @@ import java.util.Map;
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class HardOrderController {
+ /** 复用原模板的后台分批导出。 */
+ private final com.bsd.pjcloud.hard.service.impl.order.HardBatchExportService batchExportService;
+
private final HardOrderService hardwareOrderService;
+ private final HardOrderQuerySupport orderQuerySupport;
+ private final HardOrderStatisticsService orderStatisticsService;
private final HardManagerService hardManagerService;
private final HardOrderSyncService hardOrderSyncService;
private final HardOrderMapper hardOrderMapper;
- private final RemoteMchService mchService;
private final RemoteUserService userService;
+ private final RemoteMchService mchService;
/* ************************************* start 远程调用 start ************************************* */
@Inner
@@ -270,43 +277,32 @@ public class HardOrderController {
public R pageJoin(@ParameterObject Page page, @RequestBody HardOrderDto dto) {
DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL);
dataScope.setScopeDeptName("merchant_dept_id");
- PjcloudUser user = SecurityUtils.getUser();
- CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user);
- dto.setMchTypeCodes(hardTypeInfo.getHardTypeList());
- // 查询商户和下面的子商户
- List mchDtoIds = dto.getMchIds();
- if (ObjectUtil.isNotEmpty(mchDtoIds) && mchDtoIds.size() == 1) {
- Long mchId = mchDtoIds.get(0);
- List mchList = mchService.getByParentId(mchId).getData();
- if (ObjectUtil.isNotEmpty(mchList)) {
- List mchIds = mchList.stream().map(MchManageVo::getId).toList();
- mchDtoIds.addAll(mchIds);
- dto.setMchIds(mchDtoIds);
- }
- }
+ orderQuerySupport.prepare(dto);
return R.ok(hardwareOrderService.scopePageList(page, dto, dataScope));
}
+ @Operation(summary = "当前筛选条件的订单实时统计")
+ @PostMapping("/statistics")
+ @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')")
+ public R statistics(@RequestBody HardOrderDto dto) {
+ return R.ok(orderStatisticsService.overview(dto));
+ }
+
+ @Operation(summary = "当前筛选条件的商户消费实时统计")
+ @PostMapping("/statistics/merchants")
+ @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')")
+ public R merchantStatistics(@RequestBody HardOrderDto dto,
+ @RequestParam(defaultValue = "1") int current, @RequestParam(defaultValue = "20") int size) {
+ return R.ok(orderStatisticsService.merchants(dto, current, size));
+ }
+
@Operation(summary = "分页查询未识别订单", description = "分页查询未识别订单")
@PostMapping("/no-recog/page-join")
@PreAuthorize("@pms.hasPermission('hard_hardwareOrderNoRecog_view')")
public R> noRecogPageJoin(@ParameterObject Page page, @RequestBody HardOrderDto dto) {
DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL);
dataScope.setScopeDeptName("merchant_dept_id");
- PjcloudUser user = SecurityUtils.getUser();
- CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user);
- dto.setMchTypeCodes(hardTypeInfo.getHardTypeList());
- // 查询商户和下面的子商户
- List mchDtoIds = dto.getMchIds();
- if (ObjectUtil.isNotEmpty(mchDtoIds) && mchDtoIds.size() == 1) {
- Long mchId = mchDtoIds.get(0);
- List mchList = mchService.getByParentId(mchId).getData();
- if (ObjectUtil.isNotEmpty(mchList)) {
- List mchIds = mchList.stream().map(MchManageVo::getId).toList();
- mchDtoIds.addAll(mchIds);
- dto.setMchIds(mchDtoIds);
- }
- }
+ orderQuerySupport.prepare(dto);
return R.ok(hardwareOrderService.noRecogScopePageJoin(page, dto, dataScope));
}
@@ -345,75 +341,14 @@ public class HardOrderController {
@GetMapping("/export")
@PreAuthorize("@pms.hasPermission('hard_hardwareOrder_export')")
- public void export(HttpServletResponse response, HardOrderDto dto) throws IOException {
- DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL);
- dataScope.setScopeDeptName("merchant_dept_id");
- PjcloudUser user = SecurityUtils.getUser();
- CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user);
- dto.setMchTypeCodes(hardTypeInfo.getHardTypeList());
- // 查询商户和下面的子商户
- List mchDtoIds = dto.getMchIds();
- if (ObjectUtil.isNotEmpty(mchDtoIds) && mchDtoIds.size() == 1) {
- Long mchId = mchDtoIds.get(0);
- List mchList = mchService.getByParentId(mchId).getData();
- if (ObjectUtil.isNotEmpty(mchList)) {
- List mchIds = mchList.stream().map(MchManageVo::getId).toList();
- mchDtoIds.addAll(mchIds);
- dto.setMchIds(mchDtoIds);
- }
- }
- List list = hardwareOrderService.scopeList(dto, DataScope.of());
- List excelList = BeanUtils.toBean(list, HardOrderExcelVo.class);
- try (ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).withTemplate(ResourceUtil.getStream("file/订单交易汇总.xlsx")).build()) {
- WriteSheet writeSheet = EasyExcel.writerSheet().build();
- FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
- Map map = new HashMap();
- if (ObjectUtil.isNotEmpty(dto.getPayTimeList())) {
- map.put("timeSpan", " 到 ");
- map.put("startTime", LocalDateTimeUtil.format(dto.getPayTimeList()[0], DatePattern.NORM_DATETIME_PATTERN));
- map.put("endTime", LocalDateTimeUtil.format(dto.getPayTimeList()[1], DatePattern.NORM_DATETIME_PATTERN));
- }
- if (ObjectUtil.isNotEmpty(excelList)) {
- BigDecimal totalPriceSum = BigDecimal.ZERO;
- BigDecimal extraPriceSum = BigDecimal.ZERO;
- BigDecimal payPriceSum = BigDecimal.ZERO;
- Long lineNumber = 1L;
- for (HardOrderExcelVo v : excelList) {
- v.setLineNumber(lineNumber);
- lineNumber++;
- if (ObjectUtil.isNotEmpty(v.getTotalPrice())) {
- totalPriceSum = totalPriceSum.add(BigDecimal.valueOf(v.getTotalPrice()));
- v.setTotalPrice(BigDecimal.valueOf(v.getTotalPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue());
- }
- if (ObjectUtil.isNotEmpty(v.getExtraPrice())) {
- extraPriceSum = extraPriceSum.add(BigDecimal.valueOf(v.getExtraPrice()));
- v.setExtraPrice(BigDecimal.valueOf(v.getExtraPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue());
- }
- if (ObjectUtil.isNotEmpty(v.getPayPrice())) {
- payPriceSum = payPriceSum.add(BigDecimal.valueOf(v.getPayPrice()));
- v.setPayPrice(BigDecimal.valueOf(v.getPayPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue());
- }
- if (ObjectUtil.isNotEmpty(v.getHourType())) {
- HardHourTypeEnum hardHourTypeEnum = HardHourTypeEnum.valueOfByType(v.getHourType());
- if (ObjectUtil.isNotEmpty(hardHourTypeEnum)) {
- v.setHourType(hardHourTypeEnum.getDescription());
- } else {
- v.setHourType("");
- }
- }
- if (ObjectUtil.isNotEmpty(v.getPayType())) {
- HardPayTypeEnum hardPayTypeEnum = HardPayTypeEnum.valueOfByType(v.getPayType());
- if (ObjectUtil.isNotEmpty(hardPayTypeEnum)) {
- v.setPayType(hardPayTypeEnum.getDescription());
- }
- }
- }
- map.put("totalPriceSum", totalPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
- map.put("extraPriceSum", extraPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
- map.put("payPriceSum", payPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
- }
- excelWriter.fill(map, writeSheet);
- excelWriter.fill(excelList, fillConfig, writeSheet);
- }
+ public void export(HttpServletResponse response, HardOrderDto dto) throws Exception {
+ batchExportService.order(response, dto);
}
-}
\ No newline at end of file
+
+ /** 提交分批导出任务,任务成功后返回完整文件地址。 */
+ @PostMapping("/export-async")
+ @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_export')")
+ public R exportAsync(@RequestBody HardOrderDto dto) {
+ return R.ok(batchExportService.submitOrder(dto), "导出任务已提交");
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java
index 6a5975bae..7cfb3e9ee 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java
@@ -57,6 +57,9 @@ import java.util.Map;
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class HardOrderTotalController {
+ /** 复用原模板的后台分批导出。 */
+ private final com.bsd.pjcloud.hard.service.impl.order.HardBatchExportService batchExportService;
+
private final HardOrderService hardOrderService;
private final HardOrderTotalService hardOrderTotalService;
private final HardManagerService hardManagerService;
@@ -342,115 +345,30 @@ public class HardOrderTotalController {
@PostMapping("/export-cs")
@SysLog("商户交易记录导出")
@PreAuthorize("@pms.hasPermission('hard_hardTotal_export_cs')")
- public void exportTk(HttpServletResponse response, @RequestBody HardOrderDto dto) throws IOException {
- dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes());
- // 查询商户和下面的子商户
- List mchDtoIds = new ArrayList<>();
- if (ObjectUtil.isNotEmpty(dto.getMerchantId())) {
- List mchList = mchService.getByParentId(dto.getMerchantId()).getData();
- if (ObjectUtil.isNotEmpty(mchList)) {
- List mchIds = mchList.stream().map(MchManageVo::getId).toList();
- mchDtoIds.add(dto.getMerchantId());
- mchDtoIds.addAll(mchIds);
- dto.setMerchantId(null);
- dto.setMchIds(mchDtoIds);
- }
- }
- List list = hardOrderTotalService.listCs(dto);
- try (ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).withTemplate(ResourceUtil.getStream("file/商户交易金额汇总.xlsx")).build()) {
- WriteSheet writeSheet = EasyExcel.writerSheet().build();
- FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
- Map map = new HashMap();
- if (ObjectUtil.isNotEmpty(dto.getPayTimeList())) {
- map.put("timeSpan", " 到 ");
- map.put("startTime", LocalDateTimeUtil.format(dto.getPayTimeList()[0], DatePattern.NORM_DATETIME_PATTERN));
- map.put("endTime", LocalDateTimeUtil.format(dto.getPayTimeList()[1], DatePattern.NORM_DATETIME_PATTERN));
- }
- if (ObjectUtil.isNotEmpty(list)) {
- // 总消费
- BigDecimal totalPayPrice = BigDecimal.ZERO;
- BigDecimal totalPayCount = BigDecimal.ZERO;
- // 总退款
- BigDecimal totalRefundPrice = BigDecimal.ZERO;
- BigDecimal totalRefundCount = BigDecimal.ZERO;
- // 总盈利
- BigDecimal totalProfitPrice = BigDecimal.ZERO;
- BigDecimal totalProfitCount = BigDecimal.ZERO;
- // 总抽成
- BigDecimal mchPlatformRankeTotalPrice = BigDecimal.ZERO;
- Long lineNumber = 1L;
- for (HardOrderCsVo v : list) {
- v.setLineNumber(lineNumber);
- lineNumber++;
- if (ObjectUtil.isNotEmpty(v.getHardType())) {
- v.setHardType(HardTypeEnum.valueOfByType(v.getHardType()).getDescription());
- }
- // 总消费
- if (ObjectUtil.isNotEmpty(v.getTotalPayPrice())) {
- totalPayPrice = totalPayPrice.add(v.getTotalPayPrice());
- v.setTotalPayPrice(v.getTotalPayPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
- }
- if (ObjectUtil.isNotEmpty(v.getTotalPayCount())) {
- totalPayCount = totalPayCount.add(v.getTotalPayCount());
- }
- // 总退款
- if (ObjectUtil.isNotEmpty(v.getTotalRefundPrice())) {
- totalRefundPrice = totalRefundPrice.add(v.getTotalRefundPrice());
- v.setTotalRefundPrice(v.getTotalRefundPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
- }
- if (ObjectUtil.isNotEmpty(v.getTotalRefundCount())) {
- totalRefundCount = totalRefundCount.add(v.getTotalRefundCount());
- }
- // 总盈利
- if (ObjectUtil.isNotEmpty(v.getTotalProfitPrice())) {
- totalProfitPrice = totalProfitPrice.add(v.getTotalProfitPrice());
- v.setTotalProfitPrice(v.getTotalProfitPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
- }
- if (ObjectUtil.isNotEmpty(v.getTotalProfitCount())) {
- totalProfitCount = totalProfitCount.add(v.getTotalProfitCount());
- }
- // 平台抽成
- if (ObjectUtil.isNotEmpty(v.getMchPlatformRankePrice())) {
- mchPlatformRankeTotalPrice = mchPlatformRankeTotalPrice.add(v.getMchPlatformRankePrice());
- v.setMchPlatformRankePrice(v.getMchPlatformRankePrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
- }
- }
- // 总消费
- map.put("computeTotalPayPrice", totalPayPrice.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
- map.put("computeTotalPayCount", totalPayCount.toString());
- // 总退款
- map.put("computeTotalRefundPrice", totalRefundPrice.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
- map.put("computeTotalRefundCount", totalRefundCount.toString());
- // 总盈利
- map.put("computeTotalProfitPrice", totalProfitPrice.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
- map.put("computeTotalProfitCount", totalProfitCount.toString());
- // 总抽成
- map.put("mchPlatformRankeTotalPrice", mchPlatformRankeTotalPrice.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
- }
- excelWriter.fill(map, writeSheet);
- excelWriter.fill(list, fillConfig, writeSheet);
- }
+ public void exportTk(HttpServletResponse response, @RequestBody HardOrderDto dto) throws Exception {
+ batchExportService.merchant(response, dto, false);
+ }
+
+ /** 提交分批导出任务,任务成功后返回完整文件地址。 */
+ @PostMapping("/export-cs-async")
+ @PreAuthorize("@pms.hasPermission('hard_hardTotal_export_cs')")
+ public R exportTkAsync(@RequestBody HardOrderDto dto) {
+ return R.ok(batchExportService.submitMerchant(dto, false), "导出任务已提交");
}
@Operation(summary = "商户交易记录导出(按月)", description = "商户交易记录导出(按月)")
@PostMapping("/export-cs-by-month")
@SysLog("商户交易记录导出(按月)")
@PreAuthorize("@pms.hasPermission('hard_hardTotal_export_cs')")
- public void exportCsByMonth(HttpServletResponse response, @RequestBody HardOrderDto dto) {
- dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes());
- // 查询商户和下面的子商户
- List mchDtoIds = new ArrayList<>();
- if (ObjectUtil.isNotEmpty(dto.getMerchantId())) {
- List mchList = mchService.getByParentId(dto.getMerchantId()).getData();
- if (ObjectUtil.isNotEmpty(mchList)) {
- List mchIds = mchList.stream().map(MchManageVo::getId).toList();
- mchDtoIds.add(dto.getMerchantId());
- mchDtoIds.addAll(mchIds);
- dto.setMerchantId(null);
- dto.setMchIds(mchDtoIds);
- }
- }
- hardOrderTotalService.exportCsByMonth(response, dto);
+ public void exportCsByMonth(HttpServletResponse response, @RequestBody HardOrderDto dto) throws Exception {
+ batchExportService.merchant(response, dto, true);
+ }
+
+ /** 提交分批导出任务,任务成功后返回完整文件地址。 */
+ @PostMapping("/export-cs-by-month-async")
+ @PreAuthorize("@pms.hasPermission('hard_hardTotal_export_cs')")
+ public R exportCsByMonthAsync(@RequestBody HardOrderDto dto) {
+ return R.ok(batchExportService.submitMerchant(dto, true), "导出任务已提交");
}
@Operation(summary = "支付方式交易记录", description = "支付方式交易记录")
@@ -529,116 +447,14 @@ public class HardOrderTotalController {
@PostMapping("/export-dept")
@SysLog("部门交易记录导出")
@PreAuthorize("@pms.hasPermission('hard_hardTotal_export_dept')")
- public void exportDept(HttpServletResponse response, @RequestBody HardDeptTotalDto dto) throws IOException {
- List list = hardOrderTotalService.pageListDept(dto);
- try (ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).withTemplate(ResourceUtil.getStream("file/部门交易金额汇总.xlsx")).build()) {
- WriteSheet writeSheet = EasyExcel.writerSheet().build();
- FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
- Map map = new HashMap();
- if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) {
- map.put("timeSpan", " 到 ");
- map.put("startTime", LocalDateTimeUtil.format(dto.getCreateTimeList()[0], DatePattern.NORM_DATETIME_PATTERN));
- map.put("endTime", LocalDateTimeUtil.format(dto.getCreateTimeList()[1], DatePattern.NORM_DATETIME_PATTERN));
- }
- if (ObjectUtil.isNotEmpty(list)) {
- // 批量充值
- BigDecimal walletPriceSum = BigDecimal.ZERO;
- BigDecimal walletCountSum = BigDecimal.ZERO;
- // 批量扣减
- BigDecimal walletReducePriceSum = BigDecimal.ZERO;
- BigDecimal walletReduceCountSum = BigDecimal.ZERO;
- // 单次充值
- BigDecimal walletOncePriceSum = BigDecimal.ZERO;
- BigDecimal walletOnceCountSum = BigDecimal.ZERO;
- // 单次扣减
- BigDecimal walletOnceReducePriceSum = BigDecimal.ZERO;
- BigDecimal walletOnceReduceCountSum = BigDecimal.ZERO;
+ public void exportDept(HttpServletResponse response, @RequestBody HardDeptTotalDto dto) throws Exception {
+ batchExportService.department(response, dto);
+ }
- BigDecimal orderPayPriceSum = BigDecimal.ZERO;
- BigDecimal orderPayCountSum = BigDecimal.ZERO;
-
- BigDecimal orderRefundPriceSum = BigDecimal.ZERO;
- BigDecimal orderRefundContSum = BigDecimal.ZERO;
- // 盈利
- BigDecimal orderProfitPriceSum = BigDecimal.ZERO;
- BigDecimal orderProfitContSum = BigDecimal.ZERO;
- // 退还各自食堂金额
- BigDecimal walletReturnCanteenPriceSum = BigDecimal.ZERO;
- BigDecimal walletReturnCanteenCountSum = BigDecimal.ZERO;
-
- Long lineNumber = 1L;
- for (HardDeptTotalVo v : list) {
- v.setLineNumber(lineNumber);
- lineNumber++;
- // 批量充值
- if (ObjectUtil.isNotEmpty(v.getWalletPrice())) {
- walletPriceSum = walletPriceSum.add(v.getWalletPrice());
- v.setWalletPrice(v.getWalletPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
- }
- // 批量扣减
- if (ObjectUtil.isNotEmpty(v.getWalletReducePrice())) {
- walletReducePriceSum = walletReducePriceSum.add(BigDecimal.valueOf(v.getWalletReducePrice()));
- v.setWalletReducePrice(BigDecimal.valueOf(v.getWalletReducePrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).longValue());
- }
- // 单次充值
- if (ObjectUtil.isNotEmpty(v.getWalletOncePrice())) {
- walletOncePriceSum = walletOncePriceSum.add(v.getWalletOncePrice());
- v.setWalletOncePrice(v.getWalletOncePrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
- }
- // 单次扣减
- if (ObjectUtil.isNotEmpty(v.getWalletOnceReducePrice())) {
- walletOnceReducePriceSum = walletOnceReducePriceSum.add(v.getWalletOnceReducePrice());
- v.setWalletOnceReducePrice(v.getWalletOnceReducePrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
- }
- // 消费金额
- if (ObjectUtil.isNotEmpty(v.getOrderPayPrice())) {
- orderPayPriceSum = orderPayPriceSum.add(v.getOrderPayPrice());
- v.setOrderPayPrice(v.getOrderPayPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
- }
- //
- if (ObjectUtil.isNotEmpty(v.getOrderRefundPrice())) {
- orderRefundPriceSum = orderRefundPriceSum.add(v.getOrderRefundPrice());
- v.setOrderRefundPrice(v.getOrderRefundPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
- }
- //
- if (ObjectUtil.isNotEmpty(v.getOrderProfitPrice())) {
- orderProfitPriceSum = orderProfitPriceSum.add(v.getOrderProfitPrice());
- v.setOrderProfitPrice(v.getOrderProfitPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
- }
- // 退还各自食堂金额
- if (ObjectUtil.isNotEmpty(v.getWalletReturnCanteenPrice())) {
- walletReturnCanteenPriceSum = walletReturnCanteenPriceSum.add(v.getWalletReturnCanteenPrice());
- v.setWalletReturnCanteenPrice(v.getWalletReturnCanteenPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
- }
- // 批量充值
- map.put("walletPriceSum", walletPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
- map.put("walletCountSum", walletCountSum.toString());
- // 批量扣减
- map.put("walletReducePriceSum", walletReducePriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
- map.put("walletReduceCountSum", walletReduceCountSum.toString());
- // 单次充值
- map.put("walletOncePriceSum", walletOncePriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
- map.put("walletOnceCountSum", walletOnceCountSum.toString());
- // 单次扣减
- map.put("walletOnceReducePriceSum", walletOnceReducePriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
- map.put("walletOnceReduceCountSum", walletOnceReduceCountSum.toString());
-
- map.put("orderPayPriceSum", orderPayPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
- map.put("orderPayCountSum", orderPayCountSum.toString());
-
- map.put("orderRefundPriceSum", orderRefundPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
- map.put("orderRefundContSum", orderRefundContSum.toString());
-
- map.put("orderProfitPriceSum", orderProfitPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
- map.put("orderProfitContSum", orderProfitContSum.toString());
-
- // 退还各自食堂金额
- map.put("walletReturnCanteenPriceSum", walletReturnCanteenPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
- map.put("walletReturnCanteenCountSum", walletReturnCanteenCountSum.toString());
- }
- }
- excelWriter.fill(map, writeSheet);
- excelWriter.fill(list, fillConfig, writeSheet);
- }
+ /** 提交分批导出任务,任务成功后返回完整文件地址。 */
+ @PostMapping("/export-dept-async")
+ @PreAuthorize("@pms.hasPermission('hard_hardTotal_export_dept')")
+ public R exportDeptAsync(@RequestBody HardDeptTotalDto dto) {
+ return R.ok(batchExportService.submitDepartment(dto), "导出任务已提交");
}
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardThirdOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardThirdOrderController.java
new file mode 100644
index 000000000..2fac812c0
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardThirdOrderController.java
@@ -0,0 +1,63 @@
+package com.bsd.pjcloud.hard.controller.order;
+
+import com.alibaba.excel.EasyExcel;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.idempotent.annotation.Idempotent;
+import com.bsd.pjcloud.common.security.util.SecurityUtils;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
+import com.bsd.pjcloud.hard.api.dto.order.HardThirdOrderRefundDto;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardThirdOrderDetailVo;
+import com.bsd.pjcloud.hard.api.vo.order.excel.HardThirdOrderExcelVo;
+import com.bsd.pjcloud.hard.service.HardThirdOrderService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springdoc.core.annotations.ParameterObject;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/hard-third-order")
+@Tag(name = "第三方应用支付底单")
+public class HardThirdOrderController {
+ private final HardThirdOrderService thirdOrderService;
+
+ @PostMapping("/page")
+ @Operation(summary = "分页查询第三方支付底单")
+ @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')")
+ public R> page(@ParameterObject Page page, @RequestBody HardOrderDto dto) {
+ return R.ok(thirdOrderService.page(page, dto, SecurityUtils.getUser()));
+ }
+
+ @GetMapping("/{id}")
+ @Operation(summary = "查询第三方支付底单完整生命周期")
+ @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')")
+ public R detail(@PathVariable Long id) {
+ return R.ok(thirdOrderService.detail(id, SecurityUtils.getUser()));
+ }
+
+ @GetMapping("/export")
+ @Operation(summary = "导出第三方支付底单")
+ @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_export')")
+ public void export(@ParameterObject HardOrderDto dto, HttpServletResponse response) throws IOException {
+ var rows = thirdOrderService.list(dto, SecurityUtils.getUser()).stream().map(HardThirdOrderExcelVo::from).toList();
+ response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+ response.setHeader("Content-Disposition", "attachment; filename=third-payment-orders.xlsx");
+ EasyExcel.write(response.getOutputStream(), HardThirdOrderExcelVo.class).sheet("第三方支付底单").doWrite(rows);
+ }
+
+ @PostMapping("/refund")
+ @Operation(summary = "第三方支付底单按金额退款")
+ @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')")
+ @Idempotent(expireTime = 5, key = "'/hard-third-order/refund' + #dto.getOrderId()", info = "正在退款中,请勿重复操作")
+ public R refund(@Validated @RequestBody HardThirdOrderRefundDto dto) {
+ return R.ok(thirdOrderService.refund(dto, SecurityUtils.getUser()));
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java
index 3c507f54c..3132e1e42 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java
@@ -176,6 +176,9 @@ public interface HardOrderMapper extends PjcloudBaseMapper {
* @param dto 订单状态、商户、时间和钱包类型筛选条件
* @return 逐笔支付明细,bizId 为订单 ID 字符串
*/
+ /** 导出分页读取原始支付明细,按订单主键稳定排序。 */
+ Page pagePayStatisticsDetails(Page page, @Param("dto") HardOrderSumAndCountDto dto);
+
List listPayStatisticsDetails(@Param("dto") HardOrderSumAndCountDto dto);
List sumAndCountTotalByHourType(@Param("dto") HardOrderSumAndCountDto dto);
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderStatisticsMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderStatisticsMapper.java
new file mode 100644
index 000000000..51e11e45c
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderStatisticsMapper.java
@@ -0,0 +1,20 @@
+package com.bsd.pjcloud.hard.mapper.order;
+
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderStatisticsVo;
+import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsRow;
+import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsScope;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface HardOrderStatisticsMapper {
+ List overview(@Param("dto") HardOrderDto dto,
+ @Param("scope") HardOrderStatisticsScope scope);
+
+ List merchants(@Param("dto") HardOrderDto dto,
+ @Param("scope") HardOrderStatisticsScope scope,
+ @Param("offset") long offset, @Param("limit") int limit);
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardOrderStatisticsRow.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardOrderStatisticsRow.java
new file mode 100644
index 000000000..8b2c2bff4
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardOrderStatisticsRow.java
@@ -0,0 +1,15 @@
+package com.bsd.pjcloud.hard.mapper.order.model;
+
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderStatisticsVo;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDate;
+
+/** 概览一次扫描返回的支付日期、支付方式聚合行。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class HardOrderStatisticsRow extends HardOrderStatisticsVo.Totals {
+ private LocalDate date;
+ private String payType;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardOrderStatisticsScope.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardOrderStatisticsScope.java
new file mode 100644
index 000000000..c1e857195
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardOrderStatisticsScope.java
@@ -0,0 +1,7 @@
+package com.bsd.pjcloud.hard.mapper.order.model;
+
+import java.util.List;
+
+/** 仅由服务端生成的权限快照;不使用 DataScope 类型,避免聚合后再次被外层权限过滤。 */
+public record HardOrderStatisticsScope(Long tenantId, boolean all, List deptIds, String username) {
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardThirdOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardThirdOrderService.java
new file mode 100644
index 000000000..ef9b542dd
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardThirdOrderService.java
@@ -0,0 +1,21 @@
+package com.bsd.pjcloud.hard.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.common.security.service.PjcloudUser;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
+import com.bsd.pjcloud.hard.api.dto.order.HardThirdOrderRefundDto;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardThirdOrderDetailVo;
+
+import java.util.List;
+
+/** 第三方应用支付底单的后台管理。 */
+public interface HardThirdOrderService {
+ Page page(Page page, HardOrderDto dto, PjcloudUser user);
+
+ List list(HardOrderDto dto, PjcloudUser user);
+
+ HardThirdOrderDetailVo detail(Long id, PjcloudUser user);
+
+ boolean refund(HardThirdOrderRefundDto dto, PjcloudUser user);
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchExportService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchExportService.java
new file mode 100644
index 000000000..6382a7739
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchExportService.java
@@ -0,0 +1,380 @@
+package com.bsd.pjcloud.hard.service.impl.order;
+
+import cn.hutool.core.bean.BeanUtil;
+import cn.hutool.core.util.ObjectUtil;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.admin.api.feign.RemoteFileService;
+import com.bsd.pjcloud.common.asynctask.service.*;
+import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
+import com.bsd.pjcloud.common.core.util.BeanUtils;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.data.datascope.DataScope;
+import com.bsd.pjcloud.common.excel.stream.*;
+import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
+import com.bsd.pjcloud.hard.api.constant.order.*;
+import com.bsd.pjcloud.hard.api.dto.order.*;
+import com.bsd.pjcloud.hard.api.vo.order.*;
+import com.bsd.pjcloud.hard.api.vo.order.excel.HardOrderExcelVo;
+import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
+import com.bsd.pjcloud.hard.service.HardOrderService;
+import com.bsd.pjcloud.hard.service.order.HardOrderQuerySupport;
+import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
+import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.apache.poi.ss.usermodel.*;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.time.*;
+import java.util.*;
+import java.util.function.Function;
+
+/** 订单与交易统计的后台分批导出;保留当前项目模板及统计口径。 */
+@Service
+@RequiredArgsConstructor
+public class HardBatchExportService {
+ private final AsyncTaskExecutorService tasks;
+ private final HardOrderService orders;
+ private final HardOrderQuerySupport orderQuerySupport;
+ private final HardOrderTotalServiceImpl totals;
+ private final HardOrderMapper mapper;
+ private final RemoteMchService mchService;
+ private final RemoteFileService files;
+
+ /** 提交订单明细;原查询准备器继续控制当前用户的商户及筛选范围。 */
+ public AsyncTaskProgressVO submitOrder(HardOrderDto dto) {
+ HardOrderDto query = BeanUtil.toBean(dto, HardOrderDto.class);
+ return tasks.submitWithResult("hard:export:order", "订单记录导出", 1,
+ reporter -> order(query, reporter, file -> upload(file, "订单记录.xlsx")));
+ }
+
+ /** 同步订单接口也分批生成完整附件。 */
+ public void order(HttpServletResponse response, HardOrderDto dto) throws Exception {
+ order(dto, null, file -> BatchExportSupport.respond(file, "订单记录.xlsx", response));
+ }
+
+ private TResult order(HardOrderDto query, AsyncTaskProgressReporter reporter, BatchExcelExportTemplate.FileConsumer consumer) throws Exception {
+ orderQuerySupport.prepare(query);
+ Map values = BatchExportSupport.timeValues(query.getPayTimeList());
+ BigDecimal[] sums = sums(3);
+ return BatchExportSupport.template("file/订单交易汇总.xlsx", values,
+ current -> orders.scopePageList(BatchExportSupport.page(current), query, DataScope.of()),
+ row -> orderRow(BeanUtils.toBean(row, HardOrderExcelVo.class), values, sums), reporter, consumer, workbook -> { });
+ }
+
+ /** 提交商户普通或按月导出,任务配置在提交时固定。 */
+ public AsyncTaskProgressVO submitMerchant(HardOrderDto dto, boolean monthly) {
+ HardOrderDto query = BeanUtil.toBean(dto, HardOrderDto.class);
+ String name = java.time.LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH时mm分ss秒 ")) + (monthly ? "商户交易记录(按月).xlsx" : "商户交易记录.xlsx");
+ return tasks.submitWithResult(monthly ? "hard:export:merchant-month" : "hard:export:merchant", "商户交易导出", 1,
+ reporter -> merchant(query, monthly, reporter, file -> upload(file, name)));
+ }
+
+ /** 保留商户两种同步附件入口。 */
+ public void merchant(HttpServletResponse response, HardOrderDto dto, boolean monthly) throws Exception {
+ String name = java.time.LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH时mm分ss秒 ")) + (monthly ? "商户交易记录(按月).xlsx" : "商户交易记录.xlsx");
+ merchant(dto, monthly, null, file -> BatchExportSupport.respond(file, name, response));
+ }
+
+ private TResult merchant(HardOrderDto query, boolean monthly, AsyncTaskProgressReporter reporter,
+ BatchExcelExportTemplate.FileConsumer consumer) throws Exception {
+ prepareMerchant(query);
+ if (monthly) return monthly(query, reporter, consumer);
+ List rows = totals.listCsForExport(query, reporter);
+ if (reporter != null) reporter.advance("商户汇总完成,正在写入模板");
+ Map values = BatchExportSupport.timeValues(query.getPayTimeList());
+ BigDecimal[] sums = sums(7);
+ return BatchExportSupport.template("file/商户交易金额汇总.xlsx", values, pages(rows),
+ row -> merchantRow(row, values, sums), null, file -> {
+ if (reporter != null) reporter.advance("文件已生成,正在保存导出结果");
+ return consumer.consume(file);
+ }, workbook -> { });
+ }
+
+ /** 沿用原导出的当前商户与下级商户展开,远程失败时终止。 */
+ private void prepareMerchant(HardOrderDto dto) {
+ dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes());
+ if (dto.getMerchantId() == null) return;
+ R> result = mchService.getByParentId(dto.getMerchantId());
+ if (result == null || !result.isOk()) throw new AsyncTaskException("查询导出商户范围失败,请重试");
+ if (result.getData() != null && !result.getData().isEmpty()) {
+ List ids = new ArrayList<>();
+ ids.add(dto.getMerchantId());
+ result.getData().forEach(row -> ids.add(row.getId()));
+ dto.setMerchantId(null);
+ dto.setMchIds(ids);
+ }
+ }
+
+ /** 提交部门交易导出,当前历史部门快照归属规则不变。 */
+ public AsyncTaskProgressVO submitDepartment(HardDeptTotalDto dto) {
+ HardDeptTotalDto query = BeanUtil.toBean(dto, HardDeptTotalDto.class);
+ return tasks.submitWithResult("hard:export:department", "部门交易记录导出", 1,
+ reporter -> department(query, reporter, file -> upload(file, "部门交易记录导出.xlsx")));
+ }
+
+ public void department(HttpServletResponse response, HardDeptTotalDto dto) throws Exception {
+ department(dto, null, file -> BatchExportSupport.respond(file, "部门交易记录导出.xlsx", response));
+ }
+
+ private TResult department(HardDeptTotalDto query, AsyncTaskProgressReporter reporter,
+ BatchExcelExportTemplate.FileConsumer consumer) throws Exception {
+ List rows = totals.listDeptForExport(query, reporter);
+ if (reporter != null) reporter.advance("部门统计完成,正在写入模板");
+ Map values = BatchExportSupport.timeValues(query.getCreateTimeList());
+ BigDecimal[] sums = sums(16);
+ return BatchExportSupport.template("file/部门交易金额汇总.xlsx", values, pages(rows),
+ row -> departmentRow(row, values, sums), null, file -> {
+ if (reporter != null) reporter.advance("文件已生成,正在保存导出结果");
+ return consumer.consume(file);
+ }, workbook -> { });
+ }
+
+ /** 按原有年度、商户筛选取商户编号,再以每五家为一组读取月份数据。 */
+ private TResult monthly(HardOrderDto query, AsyncTaskProgressReporter reporter,
+ BatchExcelExportTemplate.FileConsumer consumer) throws Exception {
+ int year;
+ try { year = Integer.parseInt(query.getPayTimeYearStr()); }
+ catch (Exception exception) { throw new AsyncTaskException("请选择导出年份"); }
+ HardSelectOrderMerchIdsDto selection = new HardSelectOrderMerchIdsDto();
+ if (query.getMerchantId() != null) selection.setMerchIds(List.of(query.getMerchantId()));
+ selection.setMerchTypes(query.getMchTypeCodes());
+ selection.setCreateTimeList(query.getCreateTimeList());
+ selection.setPayTimeYearStr(query.getPayTimeYearStr());
+ List ids = mapper.selectOrderMerchIds(selection);
+ if (ids.isEmpty()) throw new AsyncTaskException("当前筛选条件没有可导出的商户交易");
+ if (reporter != null) reporter.updateTotal(BatchExcelExportTemplate.calculateProgressTotal(ids.size(), 5, 2), "正在按商户分批汇总月份");
+ String merchantName = "所有";
+ if (query.getMerchantId() != null) {
+ R result = mchService.getById(query.getMerchantId());
+ if (result == null || !result.isOk()) throw new AsyncTaskException("查询商户名称失败,请重试");
+ if (result.getData() != null) merchantName = result.getData().getName();
+ }
+ Map values = new HashMap<>();
+ values.put("title", year + "年" + merchantName + "合作商家流水情况统计表");
+ List monthTotals = new ArrayList<>();
+ for (int month = 1; month <= 12; month++) {
+ HardOrderCsVo.Month item = new HardOrderCsVo.Month();
+ item.setDay(String.format("%04d-%02d", year, month));
+ item.setTotalProfitPrice(0); item.setTotalProfitCount(0);
+ monthTotals.add(item);
+ }
+ Function>> fetcher = current -> {
+ int start = Math.toIntExact((current - 1) * BatchExportSupport.BATCH_SIZE);
+ int end = Math.min(start + BatchExportSupport.BATCH_SIZE, ids.size());
+ List