This commit is contained in:
dzc committed 2026-10-21 19:39:59 +08:00
1 parent c34b249d7f
commit f4c4390352
114 files changed
+8278 -631

No files matched your search

+5
View File
@@ -86,6 +86,11 @@ CREATE TABLE `third_pay_log`
COLLATE = utf8mb4_general_ci
ROW_FORMAT = DYNAMIC COMMENT ='第三方支付日志';
CREATE INDEX idx_tpl_tenant_del_ct_id
ON third_pay_log (tenant_id, del_flag, create_time, id);
CREATE INDEX idx_tpl_tenant_del_source_biz_ct_id
ON third_pay_log (tenant_id, del_flag, pay_source_type, biz_type, create_time, id);
-- ----------------------------
-- Table structure for third_key_manage
-- ----------------------------
@@ -0,0 +1,47 @@
-- 钱包流水分页接口慢查询索引优化(生产 PolarDB,只加索引不改代码)
-- 影响接口:/wallet-transaction/page-join 及 page-cz / page-jz / page-xf / page-tk / page-th / app/page
-- (后端全部走 WalletTransactionMapper.scopePageList + count,SQL 形态一致)
--
-- 慢因:wallet_transaction 只有 PRIMARY(id)、idx_dept_id(wallet_id)、
-- idx_wallet_transaction_user_time(user_id,create_time)、idx_version(wallet_id,version),
-- 管理端列表 SQL(WHERE tenant_id=? AND del_flag='0' ORDER BY create_time DESC,user_id DESC LIMIT 10
-- + 同条件 COUNT)无任何可用索引 → 全表扫描 + filesort,大表上每次翻页执行两遍。
--
-- 注意:slowlog_optimization_20260826/01_add_indexes.sql 里提过同样的两条索引,
-- 但测试服与本地库均未建过,生产大概率也没有;执行前先按下面步骤核对。
--
-- ============ 执行前核对 ============
-- SHOW INDEX FROM pjcloudx_wallet.wallet_transaction;
-- 若下述索引名已存在且列一致,跳过对应条目;列不一致先停下人工确认,不要盲目重建。
--
-- ============ 执行方式 ============
-- InnoDB 二级索引创建默认 ONLINE(不锁 DML),但大表建索引有 IO 消耗,
-- 生产 IOPS 上限 8000,请低峰期逐条执行、每条间隔观察,不要并发跑。
-- P0-1:默认列表 / 时间范围 / COUNT。
-- 覆盖 WHERE tenant_id=? AND del_flag='0' 的等值前缀,且 (create_time,user_id) 与
-- ORDER BY create_time DESC, user_id DESC 完全同序,可 Backward index scan,LIMIT 10 只读 10 行;
-- COUNT 走该索引即为覆盖索引扫描(Using index)。
CREATE INDEX idx_wallet_tx_tenant_del_ct_uid
ON pjcloudx_wallet.wallet_transaction (tenant_id, del_flag, create_time, user_id);
-- P0-2:按业务类型过滤的页面(充值/记账/消费/退款/退还)。
-- biz_type 为 tinytext,必须前缀索引;稀有类型(如退款)优化器会用本索引 ref 定位后再倒序扫。
CREATE INDEX idx_wallet_tx_tenant_del_biz_ct_uid
ON pjcloudx_wallet.wallet_transaction (tenant_id, del_flag, biz_type(8), create_time, user_id);
-- ============ 执行后验证(本地已 EXPLAIN 验证,生产建完再跑一遍确认) ============
-- 期望:key = idx_wallet_tx_tenant_del_ct_uid,Extra 含 Backward index scan 且无 Using filesort
-- EXPLAIN SELECT a.* FROM pjcloudx_wallet.wallet_transaction a
-- LEFT JOIN pjcloudx_wallet.wallet_manage m ON m.id = a.wallet_id AND m.del_flag='0'
-- WHERE a.del_flag='0' AND a.tenant_id=1
-- ORDER BY a.create_time DESC, a.user_id DESC LIMIT 10;
--
-- 期望:key = idx_wallet_tx_tenant_del_ct_uid,Extra = Using index
-- EXPLAIN SELECT COUNT(*) FROM pjcloudx_wallet.wallet_transaction a
-- WHERE a.del_flag='0' AND a.tenant_id=1;
--
-- 期望:key = idx_wallet_tx_tenant_del_biz_ct_uid,Extra 含 Backward index scan
-- EXPLAIN SELECT a.* FROM pjcloudx_wallet.wallet_transaction a
-- WHERE a.del_flag='0' AND a.tenant_id=1 AND a.biz_type IN ('9')
-- ORDER BY a.create_time DESC, a.user_id DESC LIMIT 10;
@@ -0,0 +1,25 @@
-- 订单实时统计配套索引;仅在确认执行计划后安排低峰期上线,不由应用自动执行。
-- 已有创建时间索引 idx_hard_order_tenant_del_ct_uid 可直接支持创建时间范围过滤。
-- 支付时间索引只在没有同名前导列的等效索引时创建,不重复建立宽覆盖索引。
SET @statistics_pay_index_exists = (
SELECT COUNT(*)
FROM (
SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') AS columns_in_order
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order'
GROUP BY INDEX_NAME
) existing_indexes
WHERE columns_in_order = 'tenant_id,del_flag,pay_time'
OR columns_in_order LIKE 'tenant_id,del_flag,pay_time,%'
);
SET @statistics_pay_index_ddl = IF(@statistics_pay_index_exists = 0,
'CREATE INDEX idx_hard_order_tenant_del_pay_time ON pjcloudx_hardware.hard_order (tenant_id, del_flag, pay_time)',
'SELECT ''支付时间范围索引已存在,跳过'' AS message');
PREPARE statistics_pay_index_statement FROM @statistics_pay_index_ddl;
EXECUTE statistics_pay_index_statement;
DEALLOCATE PREPARE statistics_pay_index_statement;
-- 上线验收:SHOW INDEX FROM pjcloudx_hardware.hard_order;
-- 对接口实际生成的 SQL(含租户、数据权限、日期和商户/部门/钱包筛选)执行 EXPLAIN。
-- 确认订单表使用日期范围扫描,且未出现用户/购物车展示关联、全表售后聚合或分组 COUNT。
-- 记录近三个月的大租户耗时、扫描行数和并发请求下业务查询延迟;单条统计 SQL 超时为15秒。
+27
View File
@@ -0,0 +1,27 @@
-- 第三方支付日志分页索引;在 pjcloudx_thirdparty 库执行。
-- 默认列表按租户、逻辑删除标识和创建时间查询;组合筛选增加发起方、接口类型。
-- req/res/header 等内容的 LIKE '%关键字%' 不使用 B-tree 定位,请配合请求时间缩小范围。
SET @tpl_index_ddl = IF(
(SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'third_pay_log'
AND index_name = 'idx_tpl_tenant_del_ct_id') = 0,
'ALTER TABLE third_pay_log ADD INDEX idx_tpl_tenant_del_ct_id (tenant_id, del_flag, create_time, id), ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE tpl_index_stmt FROM @tpl_index_ddl;
EXECUTE tpl_index_stmt;
DEALLOCATE PREPARE tpl_index_stmt;
SET @tpl_index_ddl = IF(
(SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'third_pay_log'
AND index_name = 'idx_tpl_tenant_del_source_biz_ct_id') = 0,
'ALTER TABLE third_pay_log ADD INDEX idx_tpl_tenant_del_source_biz_ct_id (tenant_id, del_flag, pay_source_type, biz_type, create_time, id), ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE tpl_index_stmt FROM @tpl_index_ddl;
EXECUTE tpl_index_stmt;
DEALLOCATE PREPARE tpl_index_stmt;
SET @tpl_index_ddl = NULL;
@@ -0,0 +1,79 @@
# 通用异步任务模块
`pjcloud-common-async-task` 提供可由各业务模块复用的异步事务执行、Redis 进度保存和统一进度查询接口。
## 接入方式
业务模块增加依赖后,Spring Boot 会自动装配 `AsyncTaskExecutorService`、`AsyncTaskProgressService` 和查询接口:
```xml
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-async-task</artifactId>
</dependency>
```
提交任务时由业务方指定稳定的 Redis 业务命名空间、展示名称和总数。接口同步返回初始进度,业务动作进入公共模块的受控专用线程池并在单个事务中执行:
```java
AsyncTaskProgressVO progress = asyncTaskExecutorService.submit(
"user:import:teacher", "老师用户导入", users.size(),
reporter -> users.forEach(user -> {
importUser(user);
reporter.advance("正在导入:" + user.getName());
}));
```
导出等需要在成功终态返回结果文件的任务使用兼容扩展入口,原有无结果任务的 `submit` 契约保持不变:
```java
AsyncTaskProgressVO progress = asyncTaskExecutorService.submitWithResult(
"user:export", "用户信息导出", 3, reporter -> {
String fileUrl = exportAndUpload(reporter);
return AsyncTaskResult.file(fileUrl, "用户信息.xlsx");
});
```
查询数据本身耗时且提交接口需要立即返回时,可以先用大于零的占位总数创建任务,再在异步线程查询完成后更新真实总数:
```java
AsyncTaskProgressVO progress = asyncTaskExecutorService.submitWithResult(
"user:export", "用户信息导出", 1, reporter -> {
List<UserVO> users = queryUsers();
reporter.updateTotal(users.size(), "已查询 " + users.size() + " 位用户");
return exportUsers(users, reporter);
});
```
调用端统一轮询:
```text
GET /async-task/progress?redisKey={业务命名空间}&taskId={任务ID}
```
## Redis 租户 Key 约定
项目 Redis 基础设施会根据当前租户上下文自动为 Redis Key 增加租户前缀。业务代码只负责定义业务命名空间和业务唯一标识,**不需要也不得再次手工拼接 `tenantId`**。
正确的业务 Key:
```text
async:task:progress:user:import:teacher:{userId}:{taskId}
```
错误的业务 Key:
```text
async:task:progress:user:import:teacher:{tenantId}:{userId}:{taskId}
```
错误示例会导致租户维度重复出现在最终 Redis Key 中,并可能造成同一业务在不同调用方式下无法读取彼此写入的数据。
“业务 Key 不拼接租户 ID”不代表异步线程不需要租户上下文。异步任务在访问 Redis、数据库或其他租户资源前,仍需恢复 `TenantContextHolder`,并在任务结束后清理上下文。本模块执行器已经统一处理这一步,业务动作不应重复设置租户上下文。
## 事务与进度语义
- 整个业务动作由一个 Spring 事务包裹,业务抛出异常时整体回滚。
- `completed` 表示当前执行位置;失败并回滚时不代表最终成功写入数量。
- Redis 进度默认保留 24 小时,供页面刷新或短时重连后继续查询。
- 任务进度按当前登录用户隔离,调用端不能查询其他用户创建的任务。
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common</artifactId>
<version>5.7.0</version>
</parent>
<artifactId>pjcloud-common-async-task</artifactId>
<packaging>jar</packaging>
<description>pjcloud 通用异步任务与 Redis 进度管理</description>
<dependencies>
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-excel</artifactId>
</dependency>
<!--统一响应、线程池及基础类型-->
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-core</artifactId>
</dependency>
<!--Redis 模板与异步线程所需的租户上下文-->
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-data</artifactId>
</dependency>
<!--登录用户、安全上下文及非 Web Feign Token 传递-->
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-security</artifactId>
</dependency>
<!--通用异步任务事务边界-->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
</dependency>
<!--通用进度查询接口-->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,95 @@
package com.bsd.pjcloud.common.asynctask.config;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressService;
import com.bsd.pjcloud.common.asynctask.service.impl.DefaultAsyncTaskExecutorService;
import com.bsd.pjcloud.common.asynctask.service.impl.DefaultAsyncTaskProgressService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.core.task.TaskExecutor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.transaction.support.TransactionTemplate;
/**
* 通用异步任务自动配置。
* <p>业务模块引入本公共模块后,在 Redis 和事务模板均可用时自动装配专用线程池、进度服务与事务执行器。</p>
*/
@AutoConfiguration
@AutoConfigureAfter(name = {
"com.bsd.pjcloud.common.data.cache.RedisTemplateConfiguration",
"org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration"
})
@ConditionalOnClass({RedisTemplate.class, TransactionTemplate.class})
@ConditionalOnBean({RedisTemplate.class, TransactionTemplate.class})
public class AsyncTaskAutoConfiguration {
/** 公共异步任务专用工作线程池的 Bean 名称,业务模块可按该名称替换默认线程池。 */
public static final String ASYNC_TASK_WORKER_EXECUTOR_BEAN_NAME = "commonAsyncTaskWorkerExecutor";
/** 默认核心线程数,兼顾常规导入导出并发与业务服务资源占用。 */
private static final int DEFAULT_CORE_POOL_SIZE = 2;
/** 默认最大线程数,限制大批量异步任务同时占用数据库连接和远程调用资源。 */
private static final int DEFAULT_MAX_POOL_SIZE = 8;
/** 默认等待队列容量,超过线程与队列总容量后由执行器拒绝并把任务标记为提交失败。 */
private static final int DEFAULT_QUEUE_CAPACITY = 200;
/** 服务关闭时等待正在执行任务结束的最长秒数,超过后由容器继续关闭流程。 */
private static final int DEFAULT_SHUTDOWN_AWAIT_SECONDS = 30;
/** 公共异步任务线程名称前缀,便于线程转储和日志定位。 */
private static final String DEFAULT_THREAD_NAME_PREFIX = "common-async-task-";
/**
* 装配公共异步任务专用线程池,避免 Spring Boot 默认线程池因其他业务 Executor 存在而不创建。
*
* @return 已完成容量与优雅停机配置的工作线程池
*/
@Bean(name = ASYNC_TASK_WORKER_EXECUTOR_BEAN_NAME)
@ConditionalOnMissingBean(name = ASYNC_TASK_WORKER_EXECUTOR_BEAN_NAME)
public TaskExecutor commonAsyncTaskWorkerExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(DEFAULT_CORE_POOL_SIZE);
executor.setMaxPoolSize(DEFAULT_MAX_POOL_SIZE);
executor.setQueueCapacity(DEFAULT_QUEUE_CAPACITY);
executor.setThreadNamePrefix(DEFAULT_THREAD_NAME_PREFIX);
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(DEFAULT_SHUTDOWN_AWAIT_SECONDS);
return executor;
}
/**
* 装配默认 Redis 进度服务,业务模块可通过声明同类型 Bean 完整替换默认实现。
*
* @param redisTemplate 项目统一配置的 Redis 操作模板
* @return 通用异步任务 Redis 进度服务
*/
@Bean
@ConditionalOnMissingBean
public AsyncTaskProgressService asyncTaskProgressService(RedisTemplate<String, Object> redisTemplate) {
return new DefaultAsyncTaskProgressService(redisTemplate);
}
/**
* 装配默认异步事务执行器,显式使用公共异步任务专用线程池以隔离其他业务线程池。
*
* @param asyncTaskWorkerExecutor 公共异步任务专用工作线程池
* @param asyncTaskProgressService Redis 任务进度服务
* @param transactionTemplate Spring 编程式事务模板
* @return 通用异步事务任务执行器
*/
@Bean
@ConditionalOnMissingBean
public AsyncTaskExecutorService asyncTaskExecutorService(
@Qualifier(ASYNC_TASK_WORKER_EXECUTOR_BEAN_NAME) TaskExecutor asyncTaskWorkerExecutor,
AsyncTaskProgressService asyncTaskProgressService, TransactionTemplate transactionTemplate) {
return new DefaultAsyncTaskExecutorService(asyncTaskWorkerExecutor, asyncTaskProgressService, transactionTemplate);
}
}
@@ -0,0 +1,33 @@
package com.bsd.pjcloud.common.asynctask.config;
import com.bsd.pjcloud.common.asynctask.controller.AsyncTaskProgressController;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressService;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.context.annotation.Bean;
/**
* 通用异步任务 Servlet Web 接口自动配置。
* <p>仅在 Web 应用已装配 Redis 进度服务时注册统一查询接口,非 Web 任务消费者不会引入无用端点。</p>
*/
@AutoConfiguration
@AutoConfigureAfter(AsyncTaskAutoConfiguration.class)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnBean(AsyncTaskProgressService.class)
public class AsyncTaskWebAutoConfiguration {
/**
* 装配统一进度查询接口,所有业务模块共享 {@code /async-task/progress} 契约。
*
* @param asyncTaskProgressService Redis 任务进度服务
* @return 通用异步任务进度控制器
*/
@Bean
@ConditionalOnMissingBean
public AsyncTaskProgressController asyncTaskProgressController(AsyncTaskProgressService asyncTaskProgressService) {
return new AsyncTaskProgressController(asyncTaskProgressService);
}
}
@@ -0,0 +1,40 @@
package com.bsd.pjcloud.common.asynctask.constant;
import lombok.Getter;
/**
* 通用异步任务进度状态协议。
* <p>状态编码会写入 Redis 并返回调用端,已有编码不得随意变更。</p>
*/
@Getter
public enum AsyncTaskStatusEnum {
/** 等待后台线程开始执行,Redis 与接口编码为 {@code PENDING}。 */
PENDING("PENDING", "等待执行"),
/** 后台线程正在处理任务,Redis 与接口编码为 {@code RUNNING}。 */
RUNNING("RUNNING", "执行中"),
/** 任务已完整执行成功,Redis 与接口编码为 {@code SUCCESS}。 */
SUCCESS("SUCCESS", "执行成功"),
/** 任务执行异常,Redis 与接口编码为 {@code FAILED}。 */
FAILED("FAILED", "执行失败");
/** Redis 与调用端接口共同使用的固定状态编码。 */
private final String status;
/** 当前状态未提供自定义消息时使用的中文说明。 */
private final String description;
/**
* 创建异步任务状态定义。
*
* @param status Redis 与调用端接口共同使用的固定状态编码
* @param description 当前状态的默认中文说明
*/
AsyncTaskStatusEnum(String status, String description) {
this.status = status;
this.description = description;
}
}
@@ -0,0 +1,50 @@
package com.bsd.pjcloud.common.asynctask.controller;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressService;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.core.util.R;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 通用异步任务进度查询接口。
* <p>任务业务 Key 按登录用户隔离,租户 ID 显式写入服务端任务键,只能查询本人创建且尚未过期的任务。</p>
*/
@RestController
@RequestMapping("/async-task")
@Tag(description = "async-task", name = "通用异步任务进度")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class AsyncTaskProgressController {
/** 通用 Redis 任务进度服务。 */
private final AsyncTaskProgressService asyncTaskProgressService;
/**
* 创建异步任务进度查询接口。
*
* @param asyncTaskProgressService 通用 Redis 任务进度服务
*/
public AsyncTaskProgressController(AsyncTaskProgressService asyncTaskProgressService) {
this.asyncTaskProgressService = asyncTaskProgressService;
}
/**
* 查询当前用户的异步任务进度。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 上传或任务提交接口返回的不透明任务 ID
* @return 当前任务进度;任务不存在或已过期时返回失败响应
*/
@GetMapping("/progress")
@Operation(summary = "查询异步任务进度")
public R<AsyncTaskProgressVO> progress(@RequestParam String redisKey, @RequestParam String taskId) {
AsyncTaskProgressVO progress = asyncTaskProgressService.get(redisKey, taskId);
return progress == null ? R.failed("异步任务不存在或已过期") : R.ok(progress);
}
}
@@ -0,0 +1,16 @@
package com.bsd.pjcloud.common.asynctask.service;
/**
* 通用异步任务业务动作。
* <p>动作在后台事务中执行,抛出异常会回滚整个动作并把 Redis 任务标记为失败。</p>
*/
@FunctionalInterface
public interface AsyncTaskAction {
/**
* 执行具体异步业务。
*
* @param reporter 逐项进度报告器,业务完成一个计数项目后调用一次
*/
void execute(AsyncTaskProgressReporter reporter);
}
@@ -0,0 +1,7 @@
package com.bsd.pjcloud.common.asynctask.service;
/** 允许展示给任务发起人的受控错误;禁止传入 SQL、Token 或底层异常文本。 */
public class AsyncTaskException extends RuntimeException {
/** 使用业务可读提示终止任务,不暴露底层异常。 */
public AsyncTaskException(String message) { super(message); }
}
@@ -0,0 +1,34 @@
package com.bsd.pjcloud.common.asynctask.service;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
/**
* 通用异步事务任务提交服务。
* <p>同步创建 Redis 任务后,把业务动作交给后台线程执行,并透传当前租户和登录用户上下文。</p>
*/
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("当前异步任务执行器不支持返回任务结果");
}
}
@@ -0,0 +1,27 @@
package com.bsd.pjcloud.common.asynctask.service;
/**
* 异步任务逐项进度报告器。
* <p>业务每完成一个可计数项目后调用一次,由通用执行器递增 Redis 进度。</p>
*/
@FunctionalInterface
public interface AsyncTaskProgressReporter {
/**
* 在后台任务取得真实数据量后更新总项目数。
* <p>适用于查询本身耗时、必须先返回任务 ID 的导出场景;固定总数任务无需调用。</p>
*
* @param total 后台查询得到的真实总项目数,必须大于零
* @param message 总数更新后的进度说明;为空时使用通用说明
*/
default void updateTotal(int total, String message) {
throw new UnsupportedOperationException("当前异步任务进度报告器不支持动态更新总数");
}
/**
* 报告一个项目已处理完成。
*
* @param message 当前项目完成后的进度说明;为空时使用通用说明
*/
void advance(String message);
}
@@ -0,0 +1,91 @@
package com.bsd.pjcloud.common.asynctask.service;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
/**
* 通用异步任务 Redis 进度服务。
* <p>业务 Key 按当前用户隔离,租户 ID 显式写入服务端任务键;调用方只提供业务命名空间和任务 ID。</p>
*/
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);
/**
* 将任务标记为成功,并在实现支持时保存业务动作返回的结果文件信息。
* <p>默认实现退化为原有成功状态写入,保证既有自定义进度服务无需同步修改;
* 需要向调用端返回附件的实现应覆写本方法。</p>
*
* @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);
}
@@ -0,0 +1,40 @@
package com.bsd.pjcloud.common.asynctask.service;
/**
* 通用异步任务成功结果。
* <p>导入等无附件任务可返回 {@link #empty()},导出任务可返回下载地址和文件名称;结果只会在任务成功终态写入进度对象。</p>
*
* @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);
}
}
@@ -0,0 +1,17 @@
package com.bsd.pjcloud.common.asynctask.service;
/**
* 携带成功结果的通用异步任务业务动作。
* <p>用于导出等需要在成功终态返回结果文件的任务;动作在后台事务中执行,抛出异常会回滚并标记任务失败。</p>
*/
@FunctionalInterface
public interface AsyncTaskResultAction {
/**
* 执行具体异步业务并返回成功结果。
*
* @param reporter 逐项进度报告器,业务完成一个计数项目后调用一次
* @return 任务成功终态需要返回调用端的可选结果文件信息
*/
AsyncTaskResult execute(AsyncTaskProgressReporter reporter) throws Exception;
}
@@ -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 <T, R> R template(String template, Map<String, Object> values,
Function<Long, Page<T>> fetcher, Function<T, Map<String, Object>> converter,
AsyncTaskProgressReporter reporter, BatchExcelExportTemplate.FileConsumer<R> consumer,
Consumer<XSSFWorkbook> customizer) throws Exception {
Page<T> 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<String, Object> timeValues(LocalDateTime[] range) {
Map<String, Object> 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<String, Object> 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 <T> Page<T> page(long current) {
Page<T> page = new Page<>(current, BATCH_SIZE, current == 1);
page.setMaxLimit((long) BATCH_SIZE);
return page;
}
}
@@ -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;
/**
* 通用异步事务任务提交服务默认实现。
* <p>实现会恢复提交线程的租户、安全和 Feign Token 上下文,并以单个 Spring 事务执行完整业务动作。</p>
*/
@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);
}
}
}
@@ -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 进度服务默认实现。
* <p>该实现显式拼接租户、业务命名空间、登录用户和任务 ID,兼容项目当前普通字符串 Redis Key 序列化器。</p>
*/
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<String, Object> redisTemplate;
/**
* 创建通用 Redis 进度服务。
*
* @param redisTemplate 项目统一配置的 Redis 操作模板
*/
public DefaultAsyncTaskProgressService(RedisTemplate<String, Object> 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。
* <p>租户 ID 显式进入任务键,不依赖 Redis 序列化器隐式隔离。</p>
*
* @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);
}
}
@@ -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;
/**
* 通用异步任务进度展示对象。
* <p>对象存入 Redis 并通过通用进度接口返回,可用于导入、导出等后台任务。</p>
*/
@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;
}
@@ -0,0 +1,2 @@
com.bsd.pjcloud.common.asynctask.config.AsyncTaskAutoConfiguration
com.bsd.pjcloud.common.asynctask.config.AsyncTaskWebAutoConfiguration
@@ -62,6 +62,11 @@
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-async-task</artifactId>
<version>${pjcloud.version}</version>
</dependency>
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-core</artifactId>
@@ -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,缓存工作簿中的字体与单元格样式,条纹按实际行号计算,适用于分批流式写入。现有转换器、单元格类型、数据格式及导出列保持原样。固定格式模板应继续使用自身样式,不自动注册此处理器。
@@ -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();
}
@@ -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 模板的通用分批流式导出模板。
*
* <p>该模板统一管理分页循环、预统计数量校验、SXSSF 临时文件和最终 XLSX 临时文件,业务方只需提供
* 模板初始化、分页查询、逐行写入、尾部写入、进度通知和文件消费回调。该类不依赖异步任务或文件中心,
* 可由不同业务按需组合。</p>
*/
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 <T> 分页查询返回的业务行类型
* @param <C> 模板初始化后供逐行及尾部写入共享的上下文类型
* @param <R> 最终文件消费回调的返回类型
* @return 尚未执行的导出配置器
*/
public static <T, C, R> Builder<T, C, R> 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;
}
/**
* 计算异步任务可使用的总进度步数。
*
* <p>每个查询批次占一步,文件上传、异步任务成功落库等非批次动作由业务方通过额外步数声明。</p>
*
* @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 <C> 后续逐行和尾部写入共享的上下文类型
*/
@FunctionalInterface
public interface TemplateInitializer<C> {
/**
* 在 XSSFWorkbook 转换为 SXSSFWorkbook 前初始化模板。
*
* @param workbook 可编辑的模板工作簿,生命周期由公共导出模板管理
* @return 后续写入共享的业务上下文,允许为空
* @throws Exception 模板结构不符合业务约束时抛出
*/
C initialize(XSSFWorkbook workbook) throws Exception;
}
/**
* 分页数据查询回调,业务查询必须使用稳定且唯一的排序规则。
*
* @param <T> 业务行类型
*/
@FunctionalInterface
public interface PageFetcher<T> {
/**
* 查询指定页的数据。
*
* @param pageNumber 从一开始的页码
* @param batchSize 单页最大记录数
* @return 当前页记录;无记录时可返回空列表或空值
* @throws Exception 数据查询失败时抛出
*/
List<T> fetch(long pageNumber, int batchSize) throws Exception;
}
/**
* 单行写入回调,业务可在此补充序号、格式化字段和累计跨批次汇总。
*
* @param <T> 业务行类型
* @param <C> 模板共享上下文类型
*/
@FunctionalInterface
public interface RowWriter<T, C> {
/**
* 将一条业务记录写入指定工作表行。
*
* @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 <C> 模板共享上下文类型
*/
@FunctionalInterface
public interface FooterWriter<C> {
/**
* 从预留尾部区域的第一行开始写入业务内容。
*
* @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 <R> 文件消费结果类型
*/
@FunctionalInterface
public interface FileConsumer<R> {
/**
* 在临时文件仍然存在时消费完整 XLSX 文件。
*
* @param file 临时 XLSX 文件,只在回调执行期间有效,不得异步持有
* @return 文件上传地址等业务结果
* @throws Exception 文件读取、上传或移动失败时抛出
*/
R consume(Path file) throws Exception;
}
/**
* 单次分批流式导出的链式配置器。
*
* <p>配置器是有状态且非线程安全的,应在每次导出时新建并仅由当前线程使用。</p>
*
* @param <T> 分页查询返回的业务行类型
* @param <C> 模板共享上下文类型
* @param <R> 完整文件消费结果类型
*/
public static final class Builder<T, C, R> {
/** 导出前按相同筛选条件统计的明细总数。 */
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<C> templateInitializer;
/** 业务提供的分页查询回调。 */
private PageFetcher<T> pageFetcher;
/** 业务提供的逐行写入回调。 */
private RowWriter<T, C> rowWriter;
/** 可选的业务尾部写入回调。 */
private FooterWriter<C> footerWriter;
/** 可选的批次进度通知回调。 */
private ProgressListener progressListener;
/** 业务提供的完整文件消费回调。 */
private FileConsumer<R> fileConsumer;
/**
* 创建单次导出配置器。
*
* @param expectedTotal 导出前预统计的明细总数
*/
private Builder(long expectedTotal) {
this.expectedTotal = expectedTotal;
}
/**
* 设置分页大小和 SXSSF 内存行窗口大小。
*
* @param batchSize 正整数批大小
* @return 当前配置器
*/
public Builder<T, C, R> batchSize(int batchSize) {
this.batchSize = batchSize;
return this;
}
/**
* 设置明细开始写入的 Excel 零基行号。
*
* @param dataStartRowIndex 非负的零基行号
* @return 当前配置器
*/
public Builder<T, C, R> dataStartRowIndex(int dataStartRowIndex) {
this.dataStartRowIndex = dataStartRowIndex;
return this;
}
/**
* 设置明细之后必须预留的尾部行数。
*
* @param reservedTailRowCount 合计、备注等尾部内容占用的非负行数
* @return 当前配置器
*/
public Builder<T, C, R> reservedTailRowCount(int reservedTailRowCount) {
this.reservedTailRowCount = reservedTailRowCount;
return this;
}
/**
* 设置目标工作表下标。
*
* @param sheetIndex 非负的工作表零基下标
* @return 当前配置器
*/
public Builder<T, C, R> sheetIndex(int sheetIndex) {
this.sheetIndex = sheetIndex;
return this;
}
/**
* 设置最终 XLSX 临时文件的文件名前缀。
*
* @param temporaryFilePrefix 至少三个字符的文件名前缀
* @return 当前配置器
*/
public Builder<T, C, R> temporaryFilePrefix(String temporaryFilePrefix) {
this.temporaryFilePrefix = temporaryFilePrefix;
return this;
}
/**
* 设置是否压缩 SXSSF 的临时 XML 文件。
*
* @param compressTemporaryFiles 是否启用临时 XML 压缩
* @return 当前配置器
*/
public Builder<T, C, R> compressTemporaryFiles(boolean compressTemporaryFiles) {
this.compressTemporaryFiles = compressTemporaryFiles;
return this;
}
/**
* 设置是否启用共享字符串表。
*
* @param useSharedStringsTable 是否把字符串集中写入共享表
* @return 当前配置器
*/
public Builder<T, C, R> useSharedStringsTable(boolean useSharedStringsTable) {
this.useSharedStringsTable = useSharedStringsTable;
return this;
}
/**
* 设置模板输入流工厂。
*
* @param templateSupplier 每次执行时打开一个新模板流的回调
* @return 当前配置器
*/
public Builder<T, C, R> templateSupplier(TemplateSupplier templateSupplier) {
this.templateSupplier = templateSupplier;
return this;
}
/**
* 设置模板初始化回调。
*
* @param templateInitializer 模板样式读取和占位内容清理回调
* @return 当前配置器
*/
public Builder<T, C, R> templateInitializer(TemplateInitializer<C> templateInitializer) {
this.templateInitializer = templateInitializer;
return this;
}
/**
* 设置分页查询回调。
*
* @param pageFetcher 按页码和批大小查询业务数据的回调
* @return 当前配置器
*/
public Builder<T, C, R> pageFetcher(PageFetcher<T> pageFetcher) {
this.pageFetcher = pageFetcher;
return this;
}
/**
* 设置逐行写入回调。
*
* @param rowWriter 将单条业务数据写入工作表的回调
* @return 当前配置器
*/
public Builder<T, C, R> rowWriter(RowWriter<T, C> rowWriter) {
this.rowWriter = rowWriter;
return this;
}
/**
* 设置可选的尾部写入回调。
*
* @param footerWriter 明细写完后写入合计、备注等内容的回调
* @return 当前配置器
*/
public Builder<T, C, R> footerWriter(FooterWriter<C> footerWriter) {
this.footerWriter = footerWriter;
return this;
}
/**
* 设置可选的批次进度通知回调。
*
* @param progressListener 每批数据完整写入后的通知回调
* @return 当前配置器
*/
public Builder<T, C, R> progressListener(ProgressListener progressListener) {
this.progressListener = progressListener;
return this;
}
/**
* 设置最终 XLSX 文件消费回调。
*
* @param fileConsumer 上传、移动或读取最终临时文件的回调
* @return 当前配置器
*/
public Builder<T, C, R> fileConsumer(FileConsumer<R> 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<T> 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);
}
}
}
}
@@ -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); }
}
@@ -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 <T, R> R write(BatchExcelExportTemplate.TemplateSupplier template, long total, int batchSize,
Map<String, Object> values, BatchExcelExportTemplate.PageFetcher<T> fetcher,
Function<T, Map<String, Object>> converter,
BatchExcelExportTemplate.ProgressListener progress,
BatchExcelExportTemplate.FileConsumer<R> consumer,
Consumer<XSSFWorkbook> 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.<T, Layout, R>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<String, Object> 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<String, Object> 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<CellPattern> 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<String, Object> 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<String, Object> 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<CellPattern> cells) { }
/** 单个工作簿的模板上下文,生命周期仅覆盖一次导出。 */
private record Layout(int start, RowPattern detail, List<RowPattern> footer,
List<CellRangeAddress> tailMerges, Map<Short, CellStyle> dateStyles) { }
}
@@ -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 上。
* <p>构造时固定 token;按原单元格样式和行类型缓存样式,字体仅创建两份。
* 不扫描明细、不按行创建 POI 样式,保留转换器产生的数据格式与单元格类型。</p>
*/
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<String, Double> columnWidths;
private final boolean freezeHeader;
private final boolean autoFilter;
private final boolean showGridLines;
private final Map<StyleKey, XSSFCellStyle> styles = new HashMap<>();
private final Map<Sheet, SheetLayout> layouts = new IdentityHashMap<>();
private XSSFFont headerFont;
private XSSFFont bodyFont;
public ExcelExportStyleHandler(ExcelStyleTokens tokens) {
this(tokens, Map.of());
}
public ExcelExportStyleHandler(ExcelStyleTokens tokens, Map<String, Double> 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<String, Double> 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<Integer, Integer> columnWidths = new HashMap<>();
private int lastHeaderRow = -1;
private int lastStyledRow = -1;
private int lastRow;
private int lastColumn;
}
}
@@ -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 配置覆盖。
* <p>仅供显式接入的导出使用;表头与正文独立配置,颜色为六位 RGB。</p>
*/
@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<String, @NotNull @DecimalMin("1") @DecimalMax("255") Double> 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;
}
}
@@ -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"
}
]
}
+1
View File
@@ -39,6 +39,7 @@
<module>pjcloud-common-data</module>
<module>pjcloud-common-datasource</module>
<module>pjcloud-common-excel</module>
<module>pjcloud-common-async-task</module>
<module>pjcloud-common-encrypt-api</module>
<module>pjcloud-common-feign</module>
<module>pjcloud-common-gateway</module>
@@ -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<HardOrderTypeEnum> refundList = List.of(BELOW, RESERVE, FOREIGN_TICKET, ZY, LB, FARMER);
public static final List<HardOrderTypeEnum> refundList = List.of(BELOW, RESERVE, FOREIGN_TICKET, ZY, THIRD_APP, FARMER);
public static final List<HardOrderTypeEnum> payList = List.of(BELOW, RESERVE);
@@ -55,6 +55,9 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeDeptMc
private List<String> orderPayTypeCodes;
@Schema(description = "是否有额外支付:true 有,false 无,null 不限制")
private Boolean hasExtraPrice;
private List<String> hardTypeCodes;
private List<String> 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
@@ -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;
}
@@ -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> daily = new ArrayList<>();
private List<PayType> 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<Merchant> records, int current, int size, boolean hasMore,
LocalDateTime generatedAt) {
}
}
@@ -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<HardCarVo> products;
private List<HardAfterSaleEntity> refunds;
}
@@ -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);
}
}
@@ -13,6 +13,16 @@
<artifactId>pjcloud-hardware-biz</artifactId>
<dependencies>
<!-- 通用异步导出及租户隔离的任务进度。 -->
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-async-task</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-encrypt-api</artifactId>
@@ -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;
}
@@ -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<Long> mchDtoIds = dto.getMchIds();
if (ObjectUtil.isNotEmpty(mchDtoIds) && mchDtoIds.size() == 1) {
Long mchId = mchDtoIds.get(0);
List<MchManageVo> mchList = mchService.getByParentId(mchId).getData();
if (ObjectUtil.isNotEmpty(mchList)) {
List<Long> 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<HardOrderStatisticsVo> statistics(@RequestBody HardOrderDto dto) {
return R.ok(orderStatisticsService.overview(dto));
}
@Operation(summary = "当前筛选条件的商户消费实时统计")
@PostMapping("/statistics/merchants")
@PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')")
public R<HardOrderStatisticsVo.MerchantPage> 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<Page<HardOrderVo>> 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<Long> mchDtoIds = dto.getMchIds();
if (ObjectUtil.isNotEmpty(mchDtoIds) && mchDtoIds.size() == 1) {
Long mchId = mchDtoIds.get(0);
List<MchManageVo> mchList = mchService.getByParentId(mchId).getData();
if (ObjectUtil.isNotEmpty(mchList)) {
List<Long> 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<Long> mchDtoIds = dto.getMchIds();
if (ObjectUtil.isNotEmpty(mchDtoIds) && mchDtoIds.size() == 1) {
Long mchId = mchDtoIds.get(0);
List<MchManageVo> mchList = mchService.getByParentId(mchId).getData();
if (ObjectUtil.isNotEmpty(mchList)) {
List<Long> mchIds = mchList.stream().map(MchManageVo::getId).toList();
mchDtoIds.addAll(mchIds);
dto.setMchIds(mchDtoIds);
}
}
List<HardOrderVo> list = hardwareOrderService.scopeList(dto, DataScope.of());
List<HardOrderExcelVo> 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<String, Object> map = new HashMap<String, Object>();
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);
}
}
/** 提交分批导出任务,任务成功后返回完整文件地址。 */
@PostMapping("/export-async")
@PreAuthorize("@pms.hasPermission('hard_hardwareOrder_export')")
public R<com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO> exportAsync(@RequestBody HardOrderDto dto) {
return R.ok(batchExportService.submitOrder(dto), "导出任务已提交");
}
}
@@ -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<Long> mchDtoIds = new ArrayList<>();
if (ObjectUtil.isNotEmpty(dto.getMerchantId())) {
List<MchManageVo> mchList = mchService.getByParentId(dto.getMerchantId()).getData();
if (ObjectUtil.isNotEmpty(mchList)) {
List<Long> mchIds = mchList.stream().map(MchManageVo::getId).toList();
mchDtoIds.add(dto.getMerchantId());
mchDtoIds.addAll(mchIds);
dto.setMerchantId(null);
dto.setMchIds(mchDtoIds);
}
}
List<HardOrderCsVo> 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<String, Object> map = new HashMap<String, Object>();
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<com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO> 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<Long> mchDtoIds = new ArrayList<>();
if (ObjectUtil.isNotEmpty(dto.getMerchantId())) {
List<MchManageVo> mchList = mchService.getByParentId(dto.getMerchantId()).getData();
if (ObjectUtil.isNotEmpty(mchList)) {
List<Long> 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<com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO> 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<HardDeptTotalVo> 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<String, Object> map = new HashMap<String, Object>();
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<com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO> exportDeptAsync(@RequestBody HardDeptTotalDto dto) {
return R.ok(batchExportService.submitDepartment(dto), "导出任务已提交");
}
}
@@ -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<HardOrderVo>> page(@ParameterObject Page<HardOrderVo> 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<HardThirdOrderDetailVo> 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<Boolean> refund(@Validated @RequestBody HardThirdOrderRefundDto dto) {
return R.ok(thirdOrderService.refund(dto, SecurityUtils.getUser()));
}
}
@@ -176,6 +176,9 @@ public interface HardOrderMapper extends PjcloudBaseMapper<HardOrderEntity> {
* @param dto 订单状态、商户、时间和钱包类型筛选条件
* @return 逐笔支付明细,bizId 为订单 ID 字符串
*/
/** 导出分页读取原始支付明细,按订单主键稳定排序。 */
Page<HardOrderDepartmentStatisticsDetail> pagePayStatisticsDetails(Page<HardOrderDepartmentStatisticsDetail> page, @Param("dto") HardOrderSumAndCountDto dto);
List<HardOrderDepartmentStatisticsDetail> listPayStatisticsDetails(@Param("dto") HardOrderSumAndCountDto dto);
List<HardOrderSumAndCountVo> sumAndCountTotalByHourType(@Param("dto") HardOrderSumAndCountDto dto);
@@ -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<HardOrderStatisticsRow> overview(@Param("dto") HardOrderDto dto,
@Param("scope") HardOrderStatisticsScope scope);
List<HardOrderStatisticsVo.Merchant> merchants(@Param("dto") HardOrderDto dto,
@Param("scope") HardOrderStatisticsScope scope,
@Param("offset") long offset, @Param("limit") int limit);
}
@@ -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;
}
@@ -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<Long> deptIds, String username) {
}
@@ -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<HardOrderVo> page(Page<HardOrderVo> page, HardOrderDto dto, PjcloudUser user);
List<HardOrderVo> list(HardOrderDto dto, PjcloudUser user);
HardThirdOrderDetailVo detail(Long id, PjcloudUser user);
boolean refund(HardThirdOrderRefundDto dto, PjcloudUser user);
}
@@ -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> TResult order(HardOrderDto query, AsyncTaskProgressReporter reporter, BatchExcelExportTemplate.FileConsumer<TResult> consumer) throws Exception {
orderQuerySupport.prepare(query);
Map<String, Object> 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> TResult merchant(HardOrderDto query, boolean monthly, AsyncTaskProgressReporter reporter,
BatchExcelExportTemplate.FileConsumer<TResult> consumer) throws Exception {
prepareMerchant(query);
if (monthly) return monthly(query, reporter, consumer);
List<HardOrderCsVo> rows = totals.listCsForExport(query, reporter);
if (reporter != null) reporter.advance("商户汇总完成,正在写入模板");
Map<String, Object> 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<List<MchManageVo>> result = mchService.getByParentId(dto.getMerchantId());
if (result == null || !result.isOk()) throw new AsyncTaskException("查询导出商户范围失败,请重试");
if (result.getData() != null && !result.getData().isEmpty()) {
List<Long> 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> TResult department(HardDeptTotalDto query, AsyncTaskProgressReporter reporter,
BatchExcelExportTemplate.FileConsumer<TResult> consumer) throws Exception {
List<HardDeptTotalVo> rows = totals.listDeptForExport(query, reporter);
if (reporter != null) reporter.advance("部门统计完成,正在写入模板");
Map<String, Object> 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> TResult monthly(HardOrderDto query, AsyncTaskProgressReporter reporter,
BatchExcelExportTemplate.FileConsumer<TResult> 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<Long> 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<MchManageVo> result = mchService.getById(query.getMerchantId());
if (result == null || !result.isOk()) throw new AsyncTaskException("查询商户名称失败,请重试");
if (result.getData() != null) merchantName = result.getData().getName();
}
Map<String, Object> values = new HashMap<>();
values.put("title", year + "年" + merchantName + "合作商家流水情况统计表");
List<HardOrderCsVo.Month> 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<Long, Page<Map<String, Object>>> fetcher = current -> {
int start = Math.toIntExact((current - 1) * BatchExportSupport.BATCH_SIZE);
int end = Math.min(start + BatchExportSupport.BATCH_SIZE, ids.size());
List<Map<String, Object>> rows = new ArrayList<>();
for (int from = start; from < end; from += 5) {
List<Long> batch = ids.subList(from, Math.min(from + 5, end));
HardOrderDto slice = BeanUtil.toBean(query, HardOrderDto.class);
slice.setMchIds(new ArrayList<>(batch));
Page<HardOrderCsVo> page = totals.pageListCsByMonth(new Page<>(1, 200), slice);
R<List<MchManageVo>> result = mchService.getByIds(batch);
if (result == null || !result.isOk()) throw new AsyncTaskException("查询商户名称失败,请重试");
Map<Long, String> names = new HashMap<>();
if (result.getData() != null) result.getData().forEach(item -> names.put(item.getId(), item.getName()));
for (Long id : batch) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("col0", names.getOrDefault(id, "未知商户"));
List<Map<String, Object>> columns = totals.disposeCsByMonth(id, page.getRecords(), monthTotals, year);
for (int column = 0; column < columns.size(); column++) row.put("col" + (column + 1), columns.get(column).get("col"));
rows.add(row);
}
if (reporter != null) reporter.advance("已汇总 " + Math.min(from + 5, end) + " / " + ids.size() + " 家商户");
}
double price = 0; long count = 0;
for (int i = 0; i < monthTotals.size(); i++) {
HardOrderCsVo.Month month = monthTotals.get(i);
values.put("total" + (i * 2), month.getTotalProfitPrice());
values.put("total" + (i * 2 + 1), month.getTotalProfitCount());
price += month.getTotalProfitPrice(); count += month.getTotalProfitCount();
}
values.put("total24", price); values.put("total25", count);
Page<Map<String, Object>> page = new Page<>(current, BatchExportSupport.BATCH_SIZE);
page.setTotal(ids.size()); page.setRecords(rows);
return page;
};
return BatchExportSupport.template("file/商户交易金额汇总(按月).xlsx", values, fetcher, row -> row, null, file -> {
if (reporter != null) reporter.advance("按月汇总文件已生成,正在保存导出结果");
return consumer.consume(file);
}, workbook -> {
// 原模板的合计横向填充改为等价占位符,直接流式写入,避免生成中间内存工作簿。
Sheet sheet = workbook.getSheetAt(0);
Row footer = sheet.getRow(5);
CellStyle style = footer.getCell(1).getCellStyle();
for (int i = 0; i < 26; i++) {
Cell cell = footer.getCell(i + 1, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK);
cell.setCellStyle(style); cell.setCellValue("{total" + i + "}");
}
});
}
/** 聚合结果仅含商户或部门行;业务明细已在查询阶段按批释放。 */
private static <T> Function<Long, Page<T>> pages(List<T> rows) {
return current -> {
int start = Math.toIntExact((current - 1) * BatchExportSupport.BATCH_SIZE);
int end = Math.min(start + BatchExportSupport.BATCH_SIZE, rows.size());
Page<T> page = new Page<>(current, BatchExportSupport.BATCH_SIZE);
page.setTotal(rows.size()); page.setRecords(start >= rows.size() ? List.of() : rows.subList(start, end));
return page;
};
}
private static BigDecimal[] sums(int size) {
BigDecimal[] sums = new BigDecimal[size]; Arrays.fill(sums, BigDecimal.ZERO); return sums;
}
private AsyncTaskResult upload(Path file, String name) {
String encoded = new String(name.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
R<?> result = files.innerUpload(new ExportMultipartFile(file, encoded), "export", null, "excel");
if (result == null || !result.isOk() || !(result.getData() instanceof Map<?, ?> data) || data.get("url") == null) throw new AsyncTaskException("导出文件保存失败,请重试");
return AsyncTaskResult.file(data.get("url").toString(), name);
}
/** 沿用旧 export 的字段、数值类型及合计规则。 */
private Map<String, Object> orderRow(HardOrderExcelVo v, Map<String, Object> values, BigDecimal[] sums) {
if (ObjectUtil.isNotEmpty(v.getTotalPrice())) {
sums[0] = sums[0].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())) {
sums[1] = sums[1].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())) {
sums[2] = sums[2].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());
}
}
values.put("totalPriceSum", sums[0].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
values.put("extraPriceSum", sums[1].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
values.put("payPriceSum", sums[2].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
return BeanUtil.beanToMap(v);
}
/** 沿用旧 exportTk 的字段、数值类型及合计规则。 */
private Map<String, Object> merchantRow(HardOrderCsVo v, Map<String, Object> values, BigDecimal[] sums) {
if (ObjectUtil.isNotEmpty(v.getHardType())) {
v.setHardType(HardTypeEnum.valueOfByType(v.getHardType()).getDescription());
}
// 总消费
if (ObjectUtil.isNotEmpty(v.getTotalPayPrice())) {
sums[0] = sums[0].add(v.getTotalPayPrice());
v.setTotalPayPrice(v.getTotalPayPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
}
if (ObjectUtil.isNotEmpty(v.getTotalPayCount())) {
sums[1] = sums[1].add(v.getTotalPayCount());
}
// 总退款
if (ObjectUtil.isNotEmpty(v.getTotalRefundPrice())) {
sums[2] = sums[2].add(v.getTotalRefundPrice());
v.setTotalRefundPrice(v.getTotalRefundPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
}
if (ObjectUtil.isNotEmpty(v.getTotalRefundCount())) {
sums[3] = sums[3].add(v.getTotalRefundCount());
}
// 总盈利
if (ObjectUtil.isNotEmpty(v.getTotalProfitPrice())) {
sums[4] = sums[4].add(v.getTotalProfitPrice());
v.setTotalProfitPrice(v.getTotalProfitPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
}
if (ObjectUtil.isNotEmpty(v.getTotalProfitCount())) {
sums[5] = sums[5].add(v.getTotalProfitCount());
}
// 平台抽成
if (ObjectUtil.isNotEmpty(v.getMchPlatformRankePrice())) {
sums[6] = sums[6].add(v.getMchPlatformRankePrice());
v.setMchPlatformRankePrice(v.getMchPlatformRankePrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
}
values.put("computeTotalPayPrice", sums[0].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
values.put("computeTotalPayCount", sums[1].toString());
values.put("computeTotalRefundPrice", sums[2].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
values.put("computeTotalRefundCount", sums[3].toString());
values.put("computeTotalProfitPrice", sums[4].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
values.put("computeTotalProfitCount", sums[5].toString());
values.put("mchPlatformRankeTotalPrice", sums[6].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
return BeanUtil.beanToMap(v);
}
/** 沿用旧 exportDept 的字段、数值类型及合计规则。 */
private Map<String, Object> departmentRow(HardDeptTotalVo v, Map<String, Object> values, BigDecimal[] sums) {
// 批量充值
if (ObjectUtil.isNotEmpty(v.getWalletPrice())) {
sums[0] = sums[0].add(v.getWalletPrice());
v.setWalletPrice(v.getWalletPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
}
// 批量扣减
if (ObjectUtil.isNotEmpty(v.getWalletReducePrice())) {
sums[2] = sums[2].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())) {
sums[4] = sums[4].add(v.getWalletOncePrice());
v.setWalletOncePrice(v.getWalletOncePrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
}
// 单次扣减
if (ObjectUtil.isNotEmpty(v.getWalletOnceReducePrice())) {
sums[6] = sums[6].add(v.getWalletOnceReducePrice());
v.setWalletOnceReducePrice(v.getWalletOnceReducePrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
}
// 消费金额
if (ObjectUtil.isNotEmpty(v.getOrderPayPrice())) {
sums[8] = sums[8].add(v.getOrderPayPrice());
v.setOrderPayPrice(v.getOrderPayPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
}
//
if (ObjectUtil.isNotEmpty(v.getOrderRefundPrice())) {
sums[10] = sums[10].add(v.getOrderRefundPrice());
v.setOrderRefundPrice(v.getOrderRefundPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
}
//
if (ObjectUtil.isNotEmpty(v.getOrderProfitPrice())) {
sums[12] = sums[12].add(v.getOrderProfitPrice());
v.setOrderProfitPrice(v.getOrderProfitPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
}
// 退还各自食堂金额
if (ObjectUtil.isNotEmpty(v.getWalletReturnCanteenPrice())) {
sums[14] = sums[14].add(v.getWalletReturnCanteenPrice());
v.setWalletReturnCanteenPrice(v.getWalletReturnCanteenPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP));
}
// 批量充值
values.put("walletPriceSum", sums[0].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
values.put("walletCountSum", sums[1].toString());
// 批量扣减
values.put("walletReducePriceSum", sums[2].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
values.put("walletReduceCountSum", sums[3].toString());
// 单次充值
values.put("walletOncePriceSum", sums[4].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
values.put("walletOnceCountSum", sums[5].toString());
// 单次扣减
values.put("walletOnceReducePriceSum", sums[6].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
values.put("walletOnceReduceCountSum", sums[7].toString());
values.put("orderPayPriceSum", sums[8].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
values.put("orderPayCountSum", sums[9].toString());
values.put("orderRefundPriceSum", sums[10].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
values.put("orderRefundContSum", sums[11].toString());
values.put("orderProfitPriceSum", sums[12].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
values.put("orderProfitContSum", sums[13].toString());
// 退还各自食堂金额
values.put("walletReturnCanteenPriceSum", sums[14].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
values.put("walletReturnCanteenCountSum", sums[15].toString());
return BeanUtil.beanToMap(v);
}
}
@@ -1,6 +1,11 @@
package com.bsd.pjcloud.hard.service.impl.order;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
import com.bsd.pjcloud.common.asynctask.service.BatchExportSupport;
import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate;
import java.util.function.Function;
import java.util.LinkedHashMap;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.ListUtil;
import cn.hutool.core.date.DatePattern;
@@ -219,6 +224,15 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
*/
@Override
public List<HardOrderCsVo> listCs(HardOrderDto dto) {
return listCsInternal(dto, false, null);
}
/** 导出按自然日分片,所有日期完成后复用原退款合并、部门展示与平台抽成。 */
public List<HardOrderCsVo> listCsForExport(HardOrderDto dto, AsyncTaskProgressReporter reporter) {
return listCsInternal(dto, true, reporter);
}
private List<HardOrderCsVo> listCsInternal(HardOrderDto dto, boolean exporting, AsyncTaskProgressReporter reporter) {
PjcloudUser user = SecurityUtils.getUser();
if (ObjectUtil.isEmpty(dto.getMchTypeCodes())) {
CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user);
@@ -231,7 +245,7 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
totalDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice());
DataScope totalDataScope = DataScope.of();
totalDataScope.setScopeDeptName("merchant_dept_id");
List<HardOrderCsVo> totalList = hardOrderMapper.listCs(totalDto, totalDataScope);
List<HardOrderCsVo> totalList = exporting ? queryCsExportSlices(totalDto, false, totalDataScope, reporter) : hardOrderMapper.listCs(totalDto, totalDataScope);
// 扣款总流水
HardOrderDto refundDto = BeanUtil.toBean(dto, HardOrderDto.class);
refundDto.setPayTimeList(null);
@@ -242,7 +256,7 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
refundDto.setRefundStatusCodes(List.of(HardSaleStatusEnum.COMPLETE.getStatus()));
DataScope refundDataScope = DataScope.of();
refundDataScope.setScopeDeptName("merchant_dept_id");
List<HardOrderCsVo> refundList = hardOrderMapper.listRefundCs(refundDto, refundDataScope);
List<HardOrderCsVo> refundList = exporting ? queryCsExportSlices(refundDto, true, refundDataScope, reporter) : hardOrderMapper.listRefundCs(refundDto, refundDataScope);
// 有退款没有流水的情况
if (ObjectUtil.isAllNotEmpty(totalList, refundList)) {
List<Long> totalIds = totalList.stream().map(HardOrderCsVo::getMerchantId).toList();
@@ -534,6 +548,15 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
*/
@Override
public List<HardDeptTotalVo> pageListDept(HardDeptTotalDto dto) {
return departmentRows(dto, false, null);
}
/** 导出专用历史部门归集,原始订单最多在内存保留一个批次。 */
public List<HardDeptTotalVo> listDeptForExport(HardDeptTotalDto dto, AsyncTaskProgressReporter reporter) {
return departmentRows(dto, true, reporter);
}
private List<HardDeptTotalVo> departmentRows(HardDeptTotalDto dto, boolean exporting, AsyncTaskProgressReporter reporter) {
List<SysDept> deptList = queryDepartmentList(dto);
if (ObjectUtil.isEmpty(deptList)) {
return List.of();
@@ -552,14 +575,16 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
List<WalletTransactionSumAndCountVo> walletRefundList = queryWalletStatistics(
buildWalletStatisticsDto(dto, List.of(WalletBizEnum.REFUND.getCode()), null));
List<HardOrderDepartmentStatisticsDetail> payDetails =
hardOrderMapper.listPayStatisticsDetails(buildPayStatisticsDto(dto));
Map<String, List<HardOrderDepartmentStatisticsDetail>> detailsByBizType = new LinkedHashMap<>();
detailsByBizType.put(WalletBizEnum.PAYMENT.getCode(), payDetails);
Map<String, Map<String, WalletTransactionDepartmentSnapshotVo>> transactionSnapshotMaps =
queryTransactionDepartmentSnapshotMaps(detailsByBizType);
List<HardOrderSumAndCountVo> payList = aggregatePaymentsByTransactionDepartment(payDetails,
transactionSnapshotMaps.getOrDefault(WalletBizEnum.PAYMENT.getCode(), Map.of()), dto);
List<HardOrderSumAndCountVo> payList;
if (exporting) {
payList = queryExportPayments(dto, reporter);
} else {
List<HardOrderDepartmentStatisticsDetail> payDetails = hardOrderMapper.listPayStatisticsDetails(buildPayStatisticsDto(dto));
Map<String, List<HardOrderDepartmentStatisticsDetail>> groups = new LinkedHashMap<>();
groups.put(WalletBizEnum.PAYMENT.getCode(), payDetails);
Map<String, Map<String, WalletTransactionDepartmentSnapshotVo>> snapshots = queryTransactionDepartmentSnapshotMaps(groups);
payList = aggregatePaymentsByTransactionDepartment(payDetails, snapshots.getOrDefault(WalletBizEnum.PAYMENT.getCode(), Map.of()), dto);
}
Map<Long, WalletTransactionSumAndCountVo> walletBatchMap = indexWalletStatistics(walletBatchList);
Map<Long, WalletTransactionSumAndCountVo> walletReduceMap = indexWalletStatistics(walletReduceList);
@@ -1222,4 +1247,72 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
private Map<Long, SysDept> getDeptMapByIds(List<Long> deptIds) {
return RemoteBatchQueryUtil.queryMapByIds(deptIds, ids -> remoteDeptService.getDeptByDeptIds(ids).getData());
}
/** 同一订单批次补齐钱包历史部门后立即累加,不保留全部订单及流水快照。 */
private List<HardOrderSumAndCountVo> queryExportPayments(HardDeptTotalDto dto, AsyncTaskProgressReporter reporter) {
Map<Long, HardOrderSumAndCountVo> aggregate = new LinkedHashMap<>();
HardOrderSumAndCountDto query = buildPayStatisticsDto(dto);
long processed = 0;
long expected = 0;
for (long current = 1; ; current++) {
Page<HardOrderDepartmentStatisticsDetail> page = hardOrderMapper.pagePayStatisticsDetails(BatchExportSupport.page(current), query);
if (current == 1) {
expected = page.getTotal();
if (reporter != null) reporter.updateTotal(expected == 0 ? 3 : BatchExcelExportTemplate.calculateProgressTotal(expected, BatchExportSupport.BATCH_SIZE, 3), "正在归集部门订单及钱包历史快照");
}
List<HardOrderDepartmentStatisticsDetail> details = page.getRecords();
if (details.isEmpty()) break;
processed += details.size();
Map<String, List<HardOrderDepartmentStatisticsDetail>> groups = new LinkedHashMap<>();
groups.put(WalletBizEnum.PAYMENT.getCode(), details);
Map<String, WalletTransactionDepartmentSnapshotVo> snapshots = queryTransactionDepartmentSnapshotMaps(groups).getOrDefault(WalletBizEnum.PAYMENT.getCode(), Map.of());
for (HardOrderSumAndCountVo row : aggregatePaymentsByTransactionDepartment(details, snapshots, dto)) {
HardOrderSumAndCountVo existing = aggregate.get(row.getDeptId());
if (existing == null) aggregate.put(row.getDeptId(), row);
else {
existing.setSum(Math.addExact(existing.getSum(), row.getSum()));
existing.setCount(Math.addExact(existing.getCount(), row.getCount()));
}
}
if (reporter != null) reporter.advance("已归集 " + processed + " / " + expected + " 笔订单");
}
if (processed != expected) throw new com.bsd.pjcloud.common.asynctask.service.AsyncTaskException("导出期间订单数据发生变化,请重试");
return new ArrayList<>(aggregate.values());
}
/** 日分片只累计原始金额与次数;平台抽成在完整合并后按原函数计算。 */
private List<HardOrderCsVo> queryCsExportSlices(HardOrderDto dto, boolean refund, DataScope scope, AsyncTaskProgressReporter reporter) {
LocalDateTime[] range = refund ? dto.getRefundTimeList() : dto.getPayTimeList();
if (range == null || range.length != 2 || range[0] == null || range[1] == null) {
if (reporter != null) reporter.updateTotal(3, "正在汇总商户交易");
return refund ? hardOrderMapper.listRefundCs(dto, scope) : hardOrderMapper.listCs(dto, scope);
}
long days = java.time.temporal.ChronoUnit.DAYS.between(range[0].toLocalDate(), range[1].toLocalDate()) + 1;
if (days <= 0 || days > Integer.MAX_VALUE - 3) throw new IllegalArgumentException("导出时间范围不正确");
if (reporter != null && !refund) reporter.updateTotal(Math.toIntExact(days * 2 + 3), "正在按自然日汇总商户交易");
Map<Long, HardOrderCsVo> totals = new java.util.TreeMap<>();
LocalDateTime from = range[0];
int day = 0;
while (!from.isAfter(range[1])) {
LocalDateTime next = from.toLocalDate().plusDays(1).atStartOfDay();
LocalDateTime to = next.isAfter(range[1]) ? range[1] : next.minusNanos(1);
HardOrderDto slice = BeanUtil.toBean(dto, HardOrderDto.class);
if (refund) slice.setRefundTimeList(new LocalDateTime[]{from, to});
else slice.setPayTimeList(new LocalDateTime[]{from, to});
List<HardOrderCsVo> rows = refund ? hardOrderMapper.listRefundCs(slice, scope) : hardOrderMapper.listCs(slice, scope);
for (HardOrderCsVo row : rows) {
HardOrderCsVo old = totals.get(row.getMerchantId());
if (old == null) totals.put(row.getMerchantId(), row);
else {
old.setTotalPayPrice(ObjectUtil.defaultIfNull(old.getTotalPayPrice(), BigDecimal.ZERO).add(ObjectUtil.defaultIfNull(row.getTotalPayPrice(), BigDecimal.ZERO)));
old.setTotalPayCount(ObjectUtil.defaultIfNull(old.getTotalPayCount(), BigDecimal.ZERO).add(ObjectUtil.defaultIfNull(row.getTotalPayCount(), BigDecimal.ZERO)));
if (row.getMerchantDeptId() != null && (old.getMerchantDeptId() == null || row.getMerchantDeptId() > old.getMerchantDeptId())) old.setMerchantDeptId(row.getMerchantDeptId());
}
}
if (reporter != null) reporter.advance("已汇总 " + (++day) + " / " + days + (refund ? " 天退款记录" : " 天支付记录"));
from = next;
}
return new ArrayList<>(totals.values());
}
}
@@ -0,0 +1,161 @@
package com.bsd.pjcloud.hard.service.impl.third;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.crypto.digest.DigestUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAfterSaleOperateTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardSaleStatusEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardSaleTypeEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.HardThirdOrderRefundDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleLogEntity;
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.HardThirdOrderDetailVo;
import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleLogMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.*;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Service
@RequiredArgsConstructor
public class HardThirdOrderServiceImpl implements HardThirdOrderService {
private final HardOrderService orderService;
private final HardManagerService managerService;
private final HardCarService carService;
private final HardAfterSaleService afterSaleService;
private final HardOrderMapper orderMapper;
private final HardAfterSaleLogMapper afterSaleLogMapper;
private final RemoteMchService mchService;
@Override
public Page<HardOrderVo> page(Page<HardOrderVo> page, HardOrderDto dto, PjcloudUser user) {
prepareQuery(dto, user);
return orderService.scopePageList(page, dto, scope());
}
@Override
public List<HardOrderVo> list(HardOrderDto dto, PjcloudUser user) {
prepareQuery(dto, user);
return orderService.scopeList(dto, scope());
}
@Override
public HardThirdOrderDetailVo detail(Long id, PjcloudUser user) {
HardThirdOrderDetailVo detail = new HardThirdOrderDetailVo();
detail.setOrder(scopedOrder(id, user));
detail.setProducts(carService.getByOrderId(id));
detail.setRefunds(refunds(id));
return detail;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean refund(HardThirdOrderRefundDto dto, PjcloudUser user) {
// 先验证数据权限,再锁订单;第三方 API 退款也使用同一行锁。
scopedOrder(dto.getOrderId(), user);
HardOrderEntity order = orderMapper.selectByIdForUpdate(dto.getOrderId());
if (order == null || !HardOrderTypeEnum.THIRD_APP.getType().equals(order.getOrderType())) {
throw new IllegalArgumentException("第三方支付底单不存在");
}
List<HardAfterSaleEntity> refunds = refunds(order.getId());
// hard_after_sale.no 最长 32 位;摘要保留完整请求号的幂等语义,不直接截断请求号。
String refundNo = "AT" + DigestUtil.sha256Hex(order.getId() + ":" + dto.getRequestNo()).substring(0, 30);
String legacyRefundNo = "admin_third_" + dto.getRequestNo();
for (HardAfterSaleEntity previous : refunds) {
// 兼容旧版曾用较短请求号成功落库的退款,重试时仍然识别为同一笔。
if (refundNo.equals(previous.getNo()) || legacyRefundNo.equals(previous.getNo())) {
if (!ObjectUtil.equal(previous.getRefundPrice(), dto.getRefundPrice())) {
throw new IllegalArgumentException("同一退款请求的金额不能变更");
}
if (HardSaleStatusEnum.COMPLETE.getStatus().equals(previous.getStatus())) return true;
throw new IllegalArgumentException("该退款请求正在处理中,请刷新后查看");
}
}
if (!List.of(HardOrderStatusEnum.PAY.getType(), HardOrderStatusEnum.REFUND.getType(),
HardOrderStatusEnum.CANCEL.getType()).contains(order.getOrderStatus())
|| order.getPayTime() == null || order.getWalletId() == null
|| !HardPayTypeEnum.WALLET_CARD.getType().equals(order.getPayType())) {
throw new IllegalArgumentException("仅支持已支付的第三方应用钱包底单退款");
}
if (refunds.stream().anyMatch(item -> HardSaleStatusEnum.APPLY.getStatus().equals(item.getStatus()))) {
throw new IllegalArgumentException("订单存在处理中的退款,请处理完成后重试");
}
long refunded = refunds.stream()
.filter(item -> HardSaleStatusEnum.COMPLETE.getStatus().equals(item.getStatus()))
.mapToLong(item -> item.getRefundPrice() == null ? 0 : item.getRefundPrice()).sum();
refunded = Math.max(refunded, order.getRefundPrice() == null ? 0 : order.getRefundPrice());
long remaining = (order.getPayPrice() == null ? 0 : order.getPayPrice()) - refunded;
if (dto.getRefundPrice() == null || dto.getRefundPrice() <= 0 || dto.getRefundPrice() > remaining) {
throw new IllegalArgumentException("退款金额必须大于0且不超过剩余可退金额,请刷新后重试");
}
LocalDateTime now = LocalDateTime.now();
HardAfterSaleEntity afterSale = HardAfterSaleEntity.builder()
.orderId(order.getId()).orderNo(order.getOrderNo()).userId(order.getUserId())
.type(HardSaleTypeEnum.AFTER_SALE.getType()).status(HardSaleStatusEnum.APPLY.getStatus())
.no(refundNo).way("10").refundPrice(dto.getRefundPrice())
.applyReason(dto.getApplyReason().trim()).applyDescription(dto.getApplyDescription())
.auditUserId(user.getId()).auditTime(now).auditReason("后台第三方应用底单退款")
.build();
afterSaleService.save(afterSale);
// 第三方商品是外部商品快照,退款只退钱包,不重算平台商品价格或修改平台库存。
afterSaleService.refund(afterSale.getId(), "third_admin_" + dto.getRequestNo());
afterSaleService.updateById(HardAfterSaleEntity.builder().id(afterSale.getId())
.status(HardSaleStatusEnum.COMPLETE.getStatus()).refundTime(LocalDateTime.now()).build());
afterSaleLogMapper.insert(HardAfterSaleLogEntity.builder()
.afterSaleId(afterSale.getId()).userId(user.getId())
.operateType(HardAfterSaleOperateTypeEnum.ADMIN_REFUND.getType())
.beforeStatus(HardSaleStatusEnum.APPLY.getStatus()).afterStatus(HardSaleStatusEnum.COMPLETE.getStatus())
.content("后台第三方应用底单退款完成").build());
return true;
}
private HardOrderVo scopedOrder(Long id, PjcloudUser user) {
HardOrderDto query = new HardOrderDto();
query.setId(id);
prepareQuery(query, user);
List<HardOrderVo> orders = orderService.scopeList(query, scope());
if (ObjectUtil.isEmpty(orders)) throw new IllegalArgumentException("第三方支付底单不存在或无权查看");
return orders.get(0);
}
private void prepareQuery(HardOrderDto dto, PjcloudUser user) {
dto.setOrderType(HardOrderTypeEnum.THIRD_APP.getType());
dto.setMchTypeCodes(managerService.getCurrentUserHardTypeInfo(user).getHardTypeList());
// 分页和导出使用相同的父子商户范围。
if (ObjectUtil.isNotEmpty(dto.getMchIds()) && dto.getMchIds().size() == 1) {
List<Long> ids = new ArrayList<>(dto.getMchIds());
var children = mchService.getByParentId(ids.get(0)).getData();
if (children != null) children.forEach(child -> ids.add(child.getId()));
dto.setMchIds(ids);
}
}
private DataScope scope() {
DataScope scope = DataScope.of().func(DataScopeFuncEnum.ALL);
scope.setScopeDeptName("merchant_dept_id");
return scope;
}
private List<HardAfterSaleEntity> refunds(Long orderId) {
return afterSaleService.list(new LambdaQueryWrapper<HardAfterSaleEntity>()
.eq(HardAfterSaleEntity::getOrderId, orderId)
.eq(HardAfterSaleEntity::getType, HardSaleTypeEnum.AFTER_SALE.getType())
.orderByDesc(HardAfterSaleEntity::getCreateTime).orderByDesc(HardAfterSaleEntity::getId));
}
}
@@ -21,10 +21,10 @@ import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleLogEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.config.LbCallbackConfig;
import com.bsd.pjcloud.hard.config.ThirdAppCallbackConfig;
import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleLogMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardAfterSaleService;
import com.bsd.pjcloud.hard.service.HardCarService;
import com.bsd.pjcloud.hard.service.HardOrderService;
@@ -59,13 +59,14 @@ public class HardThirdPayServiceImpl implements HardThirdPayService {
private static final String TAG = "第三方支付";
private final LbCallbackConfig lbCallbackConfig;
private final ThirdAppCallbackConfig thirdAppCallbackConfig;
private final HardOrderService hardOrderService;
private final HardCarService hardCarService;
private final HardAfterSaleService hardAfterSaleService;
private final HardAfterSaleLogMapper hardAfterSaleLogMapper;
private final HardOrderMapper hardOrderMapper;
private final RemoteMchService mchService;
private final RemoteUserService userService;
@@ -123,7 +124,7 @@ public class HardThirdPayServiceImpl implements HardThirdPayService {
products.add(v);
}
}
String subject = "美好生活物品*?";
String subject = "第三方app商品*?";
Integer orderTotalPrice = dto.getPrice();
Long parentCarId = null;
if (ObjectUtil.isNotEmpty(products)) {
@@ -182,11 +183,9 @@ public class HardThirdPayServiceImpl implements HardThirdPayService {
try {
String orderType = getOrderType(soureType);
// 订单是否存在
HardOrderEntity orderEntity = hardOrderService.getOne(new LambdaQueryWrapper<HardOrderEntity>()
.eq(HardOrderEntity::getId, dto.getPlatformOrderId())
.eq(HardOrderEntity::getOrderType, orderType)
);
if (ObjectUtil.isEmpty(orderEntity)) {
// 与后台按金额退款共享订单行锁,避免两条路径并发时超退。
HardOrderEntity orderEntity = hardOrderMapper.selectByIdForUpdate(Long.valueOf(dto.getPlatformOrderId()));
if (ObjectUtil.isEmpty(orderEntity) || !ObjectUtil.equal(orderEntity.getOrderType(), orderType)) {
baseVo.set(ThirdPayVoStatusEnum.ORDER_EXIST);
return baseVo;
}
@@ -240,6 +239,8 @@ public class HardThirdPayServiceImpl implements HardThirdPayService {
hardAfterSaleLogMapper.insert(saveLogObj);
// 执行退款
hardAfterSaleService.refund(saveObj.getId(), "zy");
hardAfterSaleService.updateById(HardAfterSaleEntity.builder().id(saveObj.getId())
.refundTime(LocalDateTime.now()).build());
// 返回
RefundVo refundVo = new RefundVo();
refundVo.setRefundTime(LocalDateTimeUtil.format(LocalDateTime.now(), DatePattern.NORM_DATETIME_PATTERN));
@@ -264,12 +265,24 @@ public class HardThirdPayServiceImpl implements HardThirdPayService {
*/
@Override
public PayBaseVo<List<PayWaterVo>> waterList(WaterDto dto, ThirdPaySoureBizTypeEnum soureType) {
PayBaseVo<List<PayWaterVo>> baseVo = new PayBaseVo<>();
if (ObjectUtil.isEmpty(soureType)) {
baseVo.set(ThirdPayVoStatusEnum.PARAMS_ERROR);
return baseVo;
}
String orderType = getOrderType(soureType);
// 根据验签后的 BIZ-TYPE 获取默认商户,与支付底单的 merchant_id 保持一致。
MchManageVo defaultMch = getLbDefaultMch(soureType);
if (ObjectUtil.isEmpty(defaultMch) || ObjectUtil.isEmpty(defaultMch.getId())) {
baseVo.set(ThirdPayVoStatusEnum.MCH_EXIST);
return baseVo;
}
Page page = new Page<>();
page.setCurrent(dto.getCurrent());
page.setSize(dto.getSize());
HardOrderDto query = new HardOrderDto();
query.setOrderType(orderType);
query.setMerchantId(defaultMch.getId());
if (ObjectUtil.isNotEmpty(dto.getOrderStatus())) {
query.setOrderStatus(dto.getOrderStatus());
}
@@ -298,7 +311,6 @@ public class HardThirdPayServiceImpl implements HardThirdPayService {
list.add(item);
}
}
PayBaseVo baseVo = new PayBaseVo<List<PayWaterVo>>();
baseVo.set(ThirdPayVoStatusEnum.SUCCESS);
baseVo.setData(list);
return baseVo;
@@ -355,10 +367,18 @@ public class HardThirdPayServiceImpl implements HardThirdPayService {
private MchManageVo getLbDefaultMch(ThirdPaySoureBizTypeEnum type) {
switch (type) {
case LB -> {
Long lbMchId = lbCallbackConfig.getLbMchId();
Long lbMchId = thirdAppCallbackConfig.getLbMchId();
if (ObjectUtil.isNotEmpty(lbMchId)) {
MchManageVo defaultMch = mchService.getById(lbMchId).getData();
log.info("查询美好生活默认商户信息 mchId |{}|defaultMch{}", lbCallbackConfig.getLbMchId(), JSONUtil.toJsonStr(defaultMch));
log.info("查询美好生活默认商户信息 mchId |{}|defaultMch{}", thirdAppCallbackConfig.getLbMchId(), JSONUtil.toJsonStr(defaultMch));
return defaultMch;
}
}
case NFNCP -> {
Long nffcpMchId = thirdAppCallbackConfig.getNffcpMchId();
if (ObjectUtil.isNotEmpty(nffcpMchId)) {
MchManageVo defaultMch = mchService.getById(nffcpMchId).getData();
log.info("查询农发农产品默认商户信息 mchId |{}|defaultMch{}", thirdAppCallbackConfig.getLbMchId(), JSONUtil.toJsonStr(defaultMch));
return defaultMch;
}
}
@@ -374,22 +394,23 @@ public class HardThirdPayServiceImpl implements HardThirdPayService {
.eq(HardAfterSaleEntity::getStatus, HardSaleStatusEnum.COMPLETE.getStatus())
);
if (ObjectUtil.isEmpty(afterList)) {
return null;
return List.of();
}
return afterList.stream().map(v -> {
PayRefundVo item = new PayRefundVo();
item.setPlatformRefundId(String.valueOf(v.getId()));
item.setThirdRefundId(v.getNo());
item.setRefundPrice(v.getRefundPrice());
item.setRefundTime(LocalDateTimeUtil.format(v.getRefundTime(), DatePattern.NORM_DATETIME_PATTERN));
LocalDateTime refundTime = v.getRefundTime() == null ? v.getAuditTime() : v.getRefundTime();
if (refundTime != null) item.setRefundTime(LocalDateTimeUtil.format(refundTime, DatePattern.NORM_DATETIME_PATTERN));
return item;
}).toList();
}
private String getOrderType(ThirdPaySoureBizTypeEnum type) {
switch (type) {
case LB -> {
return HardOrderTypeEnum.LB.getType();
case LB, NFNCP -> {
return HardOrderTypeEnum.THIRD_APP.getType();
}
}
return null;
@@ -0,0 +1,33 @@
package com.bsd.pjcloud.hard.service.order;
import cn.hutool.core.util.ObjectUtil;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/** 列表、导出和实时统计共用的商户与设备授权条件准备。 */
@Component
@RequiredArgsConstructor
public class HardOrderQuerySupport {
private final HardManagerService hardManagerService;
private final RemoteMchService mchService;
public void prepare(HardOrderDto dto) {
dto.setMchTypeCodes(hardManagerService.getCurrentUserHardTypeInfo(SecurityUtils.getUser()).getHardTypeList());
List<Long> ids = dto.getMchIds();
if (ObjectUtil.isNotEmpty(ids) && ids.size() == 1) {
var children = mchService.getByParentId(ids.get(0)).getData();
if (ObjectUtil.isNotEmpty(children)) {
List<Long> expanded = new ArrayList<>(ids);
children.forEach(child -> expanded.add(child.getId()));
dto.setMchIds(expanded.stream().distinct().toList());
}
}
}
}
@@ -0,0 +1,33 @@
package com.bsd.pjcloud.hard.service.order;
import cn.hutool.core.util.StrUtil;
import com.bsd.pjcloud.admin.api.feign.RemoteDataScopeService;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.datascope.PjcloudDefaultDataScopeHandle;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsScope;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class HardOrderStatisticsScopeResolver {
private final PjcloudDefaultDataScopeHandle scopeHandle;
public HardOrderStatisticsScopeResolver(RemoteDataScopeService dataScopeService) {
this.scopeHandle = new PjcloudDefaultDataScopeHandle(dataScopeService);
}
public HardOrderStatisticsScope resolve() {
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
throw new IllegalArgumentException("请先选择租户再进行订单统计");
}
DataScope scope = DataScope.of();
scope.setScopeDeptName("merchant_dept_id");
boolean all = Boolean.TRUE.equals(scopeHandle.calcScope(scope));
List<Long> departments = scope.getDeptList().stream().distinct().toList();
return new HardOrderStatisticsScope(tenantId, all, departments,
StrUtil.isBlank(scope.getUsername()) ? null : scope.getUsername());
}
}
@@ -0,0 +1,116 @@
package com.bsd.pjcloud.hard.service.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.HardOrderStatisticsMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.QueryTimeoutException;
import org.springframework.stereotype.Service;
import java.sql.SQLTimeoutException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.function.Supplier;
/** 只消费数据库聚合行,不加载订单明细,不缓存结果。 */
@Service
@RequiredArgsConstructor
public class HardOrderStatisticsService {
private final HardOrderStatisticsMapper mapper;
private final HardOrderQuerySupport querySupport;
private final HardOrderStatisticsScopeResolver scopeResolver;
public HardOrderStatisticsVo overview(HardOrderDto dto) {
validateRange(dto);
querySupport.prepare(dto);
var scope = scopeResolver.resolve();
var rows = execute(() -> mapper.overview(dto, scope));
HardOrderStatisticsVo result = new HardOrderStatisticsVo();
Map<LocalDate, HardOrderStatisticsVo.Daily> daily = new TreeMap<>();
Map<String, HardOrderStatisticsVo.PayType> payTypes = new LinkedHashMap<>();
for (var row : rows) {
add(result.getSummary(), row);
if (row.getDate() == null) {
add(result.getWithoutPayTime(), row);
} else {
var day = daily.computeIfAbsent(row.getDate(), date -> {
var value = new HardOrderStatisticsVo.Daily();
value.setDate(date);
return value;
});
add(day, row);
}
var payType = payTypes.computeIfAbsent(row.getPayType(), code -> {
var value = new HardOrderStatisticsVo.PayType();
value.setPayType(code);
return value;
});
add(payType, row);
}
result.setDaily(List.copyOf(daily.values()));
result.setPayTypes(List.copyOf(payTypes.values()));
result.setGeneratedAt(LocalDateTime.now());
return result;
}
public HardOrderStatisticsVo.MerchantPage merchants(HardOrderDto dto, int current, int size) {
validateRange(dto);
if (current < 1 || size < 1 || size > 100) {
throw new IllegalArgumentException("统计页码必须大于0,每页数量须为1至100");
}
querySupport.prepare(dto);
var scope = scopeResolver.resolve();
long offset = (long) (current - 1) * size;
var rows = execute(() -> mapper.merchants(dto, scope, offset, size + 1));
boolean hasMore = rows.size() > size;
return new HardOrderStatisticsVo.MerchantPage(List.copyOf(rows.subList(0, Math.min(size, rows.size()))),
current, size, hasMore, LocalDateTime.now());
}
static void validateRange(HardOrderDto dto) {
boolean create = validateRange(dto.getCreateTimeList(), "创建时间");
boolean pay = validateRange(dto.getPayTimeList(), "支付时间");
if (!create && !pay) {
throw new IllegalArgumentException("统计需选择创建时间或支付时间,单个区间不能超过三个月");
}
}
private static boolean validateRange(LocalDateTime[] range, String label) {
if (range == null || range.length == 0) return false;
if (range.length != 2 || range[0] == null || range[1] == null || range[1].isBefore(range[0])) {
throw new IllegalArgumentException(label + "需填写完整且开始时间不能晚于结束时间");
}
LocalDateTime latest = range[0].toLocalDate().plusMonths(3).atTime(LocalTime.MAX);
if (range[1].isAfter(latest)) {
throw new IllegalArgumentException(label + "区间不能超过三个月,请缩小查询范围");
}
return true;
}
private static void add(HardOrderStatisticsVo.Totals target, HardOrderStatisticsVo.Totals source) {
target.setOrderCount(Math.addExact(target.getOrderCount(), source.getOrderCount()));
target.setTotalPrice(target.getTotalPrice().add(source.getTotalPrice()));
target.setExtraPrice(target.getExtraPrice().add(source.getExtraPrice()));
target.setCouponPrice(target.getCouponPrice().add(source.getCouponPrice()));
target.setPayPrice(target.getPayPrice().add(source.getPayPrice()));
target.setRefundPrice(target.getRefundPrice().add(source.getRefundPrice()));
}
private static <T> T execute(Supplier<T> query) {
try {
return query.get();
} catch (RuntimeException exception) {
for (Throwable cause = exception; cause != null; cause = cause.getCause()) {
if (cause instanceof QueryTimeoutException || cause instanceof SQLTimeoutException) {
throw new IllegalArgumentException("统计查询超时,请缩小查询范围后重试", exception);
}
}
throw exception;
}
}
}
@@ -262,12 +262,12 @@
<select id="proudctQuery" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCarVo">
select a.product_id,
a.product_name,
a.product_pic,
a.product_price,
max(a.product_name) as product_name,
max(a.product_pic) as product_pic,
max(a.product_price) as product_price,
SUM(a.product_count) as count,
SUM(a.total_price) as totalPrice,
DATE_FORMAT(a.create_time, '%Y-%m-%d') as create_time
max(DATE_FORMAT(a.create_time, '%Y-%m-%d')) as create_time
from hard_car a
inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0' and o.hour_type is not null
@@ -287,7 +287,7 @@
</if>
</where>
group by a.product_id
order by a.create_time desc , SUM(a.product_count) desc
order by max(a.create_time) desc , SUM(a.product_count) desc
</select>
<select id="listByDept" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo">
@@ -432,8 +432,8 @@
<select id="productLikeTop" resultType="com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductLikeTopVo">
select
a.product_id as productId,
a.product_name as productName,
s.category_id as classifyId,
max(a.product_name) as productName,
max(s.category_id) as classifyId,
SUM(a.product_count) as payCount
from pjcloudx_hardware.hard_car a
inner join pjcloudx_hardware.hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0'
@@ -92,6 +92,29 @@
<sql id="whereA">
a.del_flag = '0'
<if test="dto.id != null">
and a.id = #{dto.id}
</if>
<if test="dto.orderIds != null and dto.orderIds.size() != 0">
and a.id in
<foreach collection="dto.orderIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<choose>
<when test="dto.thirdRefundState == 'NONE'">
and coalesce(a.refund_price, 0) = 0 and coalesce(a.refund_status, '0') != '10'
</when>
<when test="dto.thirdRefundState == 'PARTIAL'">
and a.refund_price &gt; 0 and a.refund_price &lt; a.pay_price
</when>
<when test="dto.thirdRefundState == 'FULL'">
and a.refund_price &gt; 0 and a.refund_price &gt;= a.pay_price
</when>
<when test="dto.thirdRefundState == 'PROCESSING'">
and a.refund_status = '10'
</when>
</choose>
<if test="dto.hardId != null">
and a.hard_id = #{dto.hardId}
</if>
@@ -217,6 +240,48 @@
</if>
</sql>
<!-- 订单列表和实时统计共用:关联筛选用 EXISTS,避免重复订单以及无筛选时的展示表关联。 -->
<sql id="orderListFilter">
<include refid="com.bsd.pjcloud.hard.mapper.order.HardOrderMapper.whereA"/>
<if test="dto.hasExtraPrice != null">
<choose>
<when test="dto.hasExtraPrice == true">and a.extra_price &gt; 0</when>
<otherwise>and (a.extra_price is null or a.extra_price &lt;= 0)</otherwise>
</choose>
</if>
<if test="(dto.hardTypeCode != null and dto.hardTypeCode != '') or (dto.hardTypeCodes != null and dto.hardTypeCodes.size() > 0)">
and exists (
select 1 from hard_manager hard_filter
where hard_filter.id = a.hard_id and hard_filter.tenant_id = a.tenant_id and hard_filter.del_flag = '0'
<if test="dto.hardTypeCode != null and dto.hardTypeCode != ''">
and hard_filter.type = #{dto.hardTypeCode}
</if>
<if test="dto.hardTypeCodes != null and dto.hardTypeCodes.size() > 0">
and hard_filter.type in
<foreach collection="dto.hardTypeCodes" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
)
</if>
<if test="dto.afterSaleNo != null and dto.afterSaleNo != ''">
and exists (
select 1 from hard_after_sale refund_filter
where refund_filter.order_id = a.id and refund_filter.del_flag = '0'
and refund_filter.tenant_id = a.tenant_id and refund_filter.no = #{dto.afterSaleNo}
)
</if>
<if test="dto.walletTypeId != null or (dto.walletTypeIdList != null and dto.walletTypeIdList.size() > 0)">
and exists (
select 1 from pjcloudx_wallet.wallet_manage wallet_filter
where wallet_filter.id = a.wallet_id and wallet_filter.tenant_id = a.tenant_id and wallet_filter.del_flag = '0'
<if test="dto.walletTypeId != null">and wallet_filter.wallet_type_id = #{dto.walletTypeId}</if>
<if test="dto.walletTypeIdList != null and dto.walletTypeIdList.size() > 0">
and wallet_filter.wallet_type_id in
<foreach collection="dto.walletTypeIdList" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
)
</if>
</sql>
<sql id="pageList">
select a.id,
a.order_no,
@@ -290,28 +355,7 @@
and latest_sale.del_flag = '0'
)
<where>
<include refid="whereA"/>
<if test="dto.hardTypeCode != null and dto.hardTypeCode != '' ">
and m.type = #{dto.hardTypeCode}
</if>
<if test="dto.hardTypeCodes != null and dto.hardTypeCodes.size() != 0">
and m.type in
<foreach collection="dto.hardTypeCodes" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.afterSaleNo != null and dto.afterSaleNo != ''">
and s.no = #{dto.afterSaleNo}
</if>
<if test="dto.walletTypeId != null">
and w_m.wallet_type_id = #{dto.walletTypeId}
</if>
<if test="dto.walletTypeIdList != null and dto.walletTypeIdList.size() != 0">
and w_m.wallet_type_id in
<foreach collection="dto.walletTypeIdList" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<include refid="orderListFilter"/>
</where>
<!-- id 作为唯一兜底排序,避免 (create_time, user_id) 相同的订单跨页读取时重复或遗漏。 -->
order by a.create_time desc, a.user_id desc, a.id desc
@@ -428,9 +472,9 @@
<select id="listSb" resultType="com.bsd.pjcloud.hard.api.vo.order.HardOrderSbVo">
select
a.hard_id,
a.merchant_id,
a.user_dept_id,
a.merchant_dept_id,
max(a.merchant_id) as merchant_id,
max(a.user_dept_id) as user_dept_id,
max(a.merchant_dept_id) as merchant_dept_id,
sum(a.pay_price) as totalPayPrice,
count(0) as totalPayCount
from hard_order a
@@ -450,10 +494,10 @@
<select id="sbTotal" resultType="com.bsd.pjcloud.hard.api.vo.order.HardOrderSbVo">
select
a.hard_id,
a.merchant_id,
a.user_dept_id,
a.merchant_dept_id,
max(a.hard_id) as hard_id,
max(a.merchant_id) as merchant_id,
max(a.user_dept_id) as user_dept_id,
max(a.merchant_dept_id) as merchant_dept_id,
sum(a.pay_price) as totalPayPrice,
count(0) as totalPayCount
from hard_order a
@@ -472,9 +516,9 @@
<select id="listRefundSb" resultType="com.bsd.pjcloud.hard.api.vo.order.HardOrderSbVo">
select a.hard_id,
a.merchant_id,
a.user_dept_id,
a.merchant_dept_id,
max(a.merchant_id) as merchant_id,
max(a.user_dept_id) as user_dept_id,
max(a.merchant_dept_id) as merchant_dept_id,
sum(r.refund_price) as totalPayPrice,
count(0) as totalPayCount
from hard_after_sale r
@@ -503,10 +547,10 @@
</select>
<select id="refundSbTotal" resultType="com.bsd.pjcloud.hard.api.vo.order.HardOrderSbVo">
select a.hard_id,
a.merchant_id,
a.user_dept_id,
a.merchant_dept_id,
select max(a.hard_id) as hard_id,
max(a.merchant_id) as merchant_id,
max(a.user_dept_id) as user_dept_id,
max(a.merchant_dept_id) as merchant_dept_id,
sum(r.refund_price) as totalPayPrice,
count(0) as totalPayCount
from hard_after_sale r
@@ -536,8 +580,8 @@
<select id="listCs" resultType="com.bsd.pjcloud.hard.api.vo.order.HardOrderCsVo">
select
a.merchant_id,
a.user_dept_id,
a.merchant_dept_id,
max(a.user_dept_id) as user_dept_id,
max(a.merchant_dept_id) as merchant_dept_id,
sum(a.pay_price) as totalPayPrice,
count(0) as totalPayCount
from hard_order a
@@ -563,7 +607,7 @@
</where>
group by a.merchant_id
order by
CASE a.merchant_type
CASE max(a.merchant_type)
WHEN 1 THEN 1
WHEN 3 THEN 2
WHEN 0 THEN 3
@@ -573,8 +617,8 @@
<select id="listRefundCs" resultType="com.bsd.pjcloud.hard.api.vo.order.HardOrderCsVo">
select a.merchant_id,
a.user_dept_id,
a.merchant_dept_id,
max(a.user_dept_id) as user_dept_id,
max(a.merchant_dept_id) as merchant_dept_id,
sum(ifnull(r.refund_price, 0)) as totalPayPrice,
count(0) as totalPayCount
from hard_after_sale r
@@ -604,7 +648,7 @@
</where>
group by a.merchant_id
order by
CASE a.merchant_type
CASE max(a.merchant_type)
WHEN 1 THEN 1
WHEN 3 THEN 2
WHEN 0 THEN 3
@@ -638,7 +682,7 @@
</where>
group by a.merchant_id,DATE_FORMAT(r.audit_time, '%Y-%m')
order by DATE_FORMAT(r.audit_time, '%Y-%m'),
CASE a.merchant_type
CASE max(a.merchant_type)
WHEN 1 THEN 1
WHEN 3 THEN 2
WHEN 0 THEN 3
@@ -674,7 +718,7 @@
</select>
<select id="listCsTotal" resultType="com.bsd.pjcloud.hard.api.vo.order.HardOrderCsVo">
select a.merchant_id,
select max(a.merchant_id) as merchant_id,
sum(a.pay_price) as totalPayPrice,
count(0) as totalPayCount
from hard_order a
@@ -686,7 +730,7 @@
<select id="listOrerPayType" resultType="com.bsd.pjcloud.hard.api.vo.order.HardOrderPayTypeVo">
select
a.pay_type,
a.merchant_dept_id,
max(a.merchant_dept_id) as merchant_dept_id,
sum(a.pay_price) as totalPayPrice,
count(0) as totalPayCount
from hard_order a
@@ -698,7 +742,7 @@
<select id="listOrerRefundPayType" resultType="com.bsd.pjcloud.hard.api.vo.order.HardOrderPayTypeVo">
select a.pay_type,
a.merchant_dept_id,
max(a.merchant_dept_id) as merchant_dept_id,
sum(r.refund_price) as totalPayPrice,
count(0) as totalPayCount
from hard_after_sale r
@@ -819,6 +863,49 @@
</where>
</select>
<select id="pagePayStatisticsDetails"
resultType="com.bsd.pjcloud.hard.mapper.order.model.HardOrderDepartmentStatisticsDetail">
select
cast(a.id as char) as bizId,
a.wallet_id as walletId,
a.user_dept_id as deptId,
a.pay_price as sum,
1 as count
from hard_order a
left join pjcloudx_wallet.wallet_manage w_m ON w_m.id = a.wallet_id
<where>
a.del_flag = 0
<if test="dto.walletTypeId != null">
and w_m.wallet_type_id = #{dto.walletTypeId}
</if>
<if test="dto.walletTypeIdList != null and dto.walletTypeIdList.size() != 0">
and w_m.wallet_type_id in
<foreach collection="dto.walletTypeIdList" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.isRefundSuccess != null and dto.isRefundSuccess == true ">
and (a.refund_status is null or a.refund_status not in ('20'))
</if>
<if test="dto.orderStatusCodes != null and dto.orderStatusCodes.size() != 0">
and a.order_status in
<foreach collection="dto.orderStatusCodes" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.createTimeList != null and dto.createTimeList.length == 2">
and a.create_time between #{dto.createTimeList[0]} and #{dto.createTimeList[1]}
</if>
<if test="dto.mchIds != null and dto.mchIds.size() != 0">
and a.merchant_id in
<foreach collection="dto.mchIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
</where>
order by a.id desc
</select>
<select id="refundSumAndCount" resultType="com.bsd.pjcloud.hard.api.vo.order.HardOrderSumAndCountVo">
select o.user_dept_id as deptId,sum(ifnull(a.refund_price, 0)) as sum, count(0) as count
from hard_after_sale a
@@ -988,7 +1075,7 @@
SUM(c.total_price) as totalPrice,
count(distinct o.id) as buyCount,
count(distinct o.user_id) as personCount,
s.category_id as classifyId
max(s.category_id) as classifyId
from hard_car c
inner join hard_parent_car p on c.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0' and o.hour_type is not null and
@@ -1005,12 +1092,12 @@
<select id="groupByTime" resultType="com.bsd.pjcloud.hard.api.vo.hard.PanelPorductVo">
select
date(o.create_time) as time,
o.hour_type as hourType,
max(o.hour_type) as hourType,
SUM(c.product_count) as buyProductCount,
SUM(c.total_price) as totalPrice,
count(distinct o.id) as buyCount,
count(distinct o.user_id) as personCount,
s.category_id as classifyId
max(s.category_id) as classifyId
from hard_car c
inner join hard_parent_car p on c.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0'
@@ -1036,7 +1123,7 @@
SUM(c.total_price) as totalPrice,
count(distinct o.id) as buyCount,
count(distinct o.user_id) as personCount,
s.category_id as classifyId
max(s.category_id) as classifyId
from hard_car c
inner join hard_parent_car p on c.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0'
@@ -1056,13 +1143,13 @@
<select id="groupByMonthAndHourType" resultType="com.bsd.pjcloud.hard.api.vo.hard.PanelPorductVo">
select
date(o.create_time) as time,
max(date(o.create_time)) as time,
o.hour_type as hourType,
SUM(c.product_count) as buyProductCount,
SUM(c.total_price) as totalPrice,
count(distinct o.id) as buyCount,
count(distinct o.user_id) as personCount,
s.category_id as classifyId
max(s.category_id) as classifyId
from hard_car c
inner join hard_parent_car p on c.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0'
@@ -1086,7 +1173,7 @@
SUM(c.total_price) as totalPrice,
count(distinct o.id) as buyCount,
count(distinct o.user_id) as personCount,
s.category_id as classifyId
max(s.category_id) as classifyId
from hard_car c
inner join hard_parent_car p on c.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0'
@@ -1127,13 +1214,13 @@
</select>
<select id="repurchase" resultType="com.bsd.pjcloud.hard.api.vo.hard.PanelPorductVo">
select o.merchant_id as mchId,
c.product_name as productName,
select max(o.merchant_id) as mchId,
max(c.product_name) as productName,
sum(c.product_count) as buyProductCount,
SUM(c.total_price) as totalPrice,
count(distinct o.id) as buyCount,
count(distinct o.user_id) as personCount,
s.category_id as classifyId
max(s.category_id) as classifyId
from hard_car c
inner join hard_parent_car p on c.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0'
@@ -1154,7 +1241,7 @@
<select id="shopGroupByMonthAndHourType" resultType="com.bsd.pjcloud.hard.api.vo.hard.PanelPorductVo">
select o.merchant_id as mchId,
c.product_name as productName,
max(c.product_name) as productName,
sum(c.product_count) as buyProductCount,
SUM(c.total_price) as totalPrice,
count(distinct o.id) as buyCount,
@@ -1180,7 +1267,7 @@
<select id="shopGroupByTimeAndHourType" resultType="com.bsd.pjcloud.hard.api.vo.hard.PanelPorductVo">
select o.merchant_id as mchId,
c.product_name as productName,
max(c.product_name) as productName,
sum(c.product_count) as buyProductCount,
SUM(c.total_price) as totalPrice,
count(distinct o.id) as buyCount,
@@ -1206,7 +1293,7 @@
<select id="shopGroupByProduct" resultType="com.bsd.pjcloud.hard.api.vo.hard.PanelPorductVo">
select c.product_id as productId,
c.product_name as productName,
max(c.product_name) as productName,
SUM(c.product_count) as buyProductCount,
SUM(c.total_price) as totalPrice,
count(distinct o.id) as buyCount,
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bsd.pjcloud.hard.mapper.order.HardOrderStatisticsMapper">
<sql id="totals">
count(*) as orderCount,
coalesce(sum(a.total_price), 0) as totalPrice,
coalesce(sum(a.extra_price), 0) as extraPrice,
coalesce(sum(a.coupon_price), 0) as couponPrice,
coalesce(sum(a.pay_price), 0) as payPrice,
coalesce(sum(a.refund_price), 0) as refundPrice
</sql>
<sql id="filteredOrders">
<include refid="com.bsd.pjcloud.hard.mapper.order.HardOrderMapper.orderListFilter"/>
and a.tenant_id = #{scope.tenantId}
<!-- 必须在 GROUP BY / LIMIT 前过滤权限,不能使用聚合后的 DataScope 外层包装。 -->
<if test="!scope.all">
<choose>
<when test="scope.username != null or scope.deptIds.size() > 0">
and (
<if test="scope.username != null">
a.create_by = #{scope.username}
</if>
<if test="scope.username != null and scope.deptIds.size() > 0">or</if>
<if test="scope.deptIds.size() > 0">
a.merchant_dept_id in
<foreach collection="scope.deptIds" item="department" open="(" close=")" separator=",">
#{department}
</foreach>
</if>
)
</when>
<otherwise>and 1 = 0</otherwise>
</choose>
</if>
</sql>
<!-- 一次范围扫描生成概览的全部可加总指标;不关联展示所用的用户、商户、售后或购物车明细。 -->
<select id="overview" resultType="com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsRow"
timeout="15" useCache="false" flushCache="true">
select date(a.pay_time) as date, a.pay_type as payType, <include refid="totals"/>
from hard_order a
<where><include refid="filteredOrders"/></where>
group by date(a.pay_time), a.pay_type
order by date(a.pay_time), a.pay_type
</select>
<!-- 先聚合和分页,再查这一页的商户名称;多取一条判断下一页,不执行分组 COUNT。 -->
<select id="merchants" resultType="com.bsd.pjcloud.hard.api.vo.order.HardOrderStatisticsVo$Merchant"
timeout="15" useCache="false" flushCache="true">
select grouped.*,
case when grouped.merchantId is null then '未关联商户'
when mch.id is null then concat('已删除或不存在的商户(', grouped.merchantId, ')')
else mch.name end as merchantName
from (
select a.merchant_id as merchantId, <include refid="totals"/>
from hard_order a
<where><include refid="filteredOrders"/></where>
group by a.merchant_id
order by payPrice desc, a.merchant_id
limit #{limit} offset #{offset}
) grouped
left join pjcloudx_wallet.mch_manage mch
on mch.id = grouped.merchantId and mch.tenant_id = #{scope.tenantId} and mch.del_flag = '0'
order by grouped.payPrice desc, grouped.merchantId
</select>
</mapper>
@@ -0,0 +1,118 @@
package com.bsd.pjcloud.hard.service.export;
import com.bsd.pjcloud.common.asynctask.service.*;
import com.bsd.pjcloud.common.asynctask.service.impl.*;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.NonWebTokenContextHolder;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import org.junit.jupiter.api.*;
import org.springframework.core.task.TaskExecutor;
import org.springframework.core.task.TaskRejectedException;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.support.SimpleTransactionStatus;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** 不依赖真实 Redis,验证任务归属、百分比终态及线程上下文生命周期。 */
class AsyncExportTaskTest {
@AfterEach void cleanup() {
TenantContextHolder.clear(); SecurityContextHolder.clearContext(); NonWebTokenContextHolder.clear();
}
private void login(long tenant, long userId) {
PjcloudUser user = mock(PjcloudUser.class);
when(user.getId()).thenReturn(userId);
OAuth2AccessToken token = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "export-test-token", Instant.now(), Instant.now().plusSeconds(60));
SecurityContextHolder.getContext().setAuthentication(new BearerTokenAuthentication(user, token, List.of()));
TenantContextHolder.setTenantId(tenant);
}
@Test void progressIsIsolatedByTenantAndOwnerAndOnlyReadyFilesReach100() {
RedisTemplate<String, Object> redis = mock(RedisTemplate.class);
ValueOperations<String, Object> values = mock(ValueOperations.class);
when(redis.opsForValue()).thenReturn(values);
Map<String, Object> storage = new HashMap<>();
doAnswer(call -> { storage.put(call.getArgument(0), call.getArgument(1)); return null; }).when(values).set(anyString(), any(), eq(Duration.ofHours(24)));
when(values.get(anyString())).thenAnswer(call -> storage.get(call.getArgument(0)));
DefaultAsyncTaskProgressService service = new DefaultAsyncTaskProgressService(redis);
login(10, 1);
AsyncTaskProgressVO task = service.create("wallet:export:xf", "导出", 2);
service.start(task.getRedisKey(), task.getTaskId());
service.update(task.getRedisKey(), task.getTaskId(), 2, "正在保存文件");
assertTrue(service.get(task.getRedisKey(), task.getTaskId()).getPercentage() < 100);
login(20, 1);
assertNull(service.get(task.getRedisKey(), task.getTaskId()));
login(10, 2);
assertNull(service.get(task.getRedisKey(), task.getTaskId()));
login(10, 1);
service.success(task.getRedisKey(), task.getTaskId(), "完成", AsyncTaskResult.file("/file/test", "测试.xlsx"));
AsyncTaskProgressVO result = service.get(task.getRedisKey(), task.getTaskId());
assertEquals(100, result.getPercentage()); assertTrue(result.getFinished()); assertTrue(result.getSuccess());
assertEquals("/file/test", result.getResultFileUrl());
assertThrows(IllegalArgumentException.class, () -> service.get("../../", task.getTaskId()));
}
@Test void executorRestoresAndClearsTenantAuthenticationAndFeignToken() {
login(10, 1);
List<Runnable> queue = new ArrayList<>();
AsyncTaskProgressService progress = mock(AsyncTaskProgressService.class);
AsyncTaskProgressVO initial = initial();
when(progress.create(anyString(), anyString(), anyInt())).thenReturn(initial);
TransactionTemplate transaction = mock(TransactionTemplate.class);
when(transaction.execute(any())).thenAnswer(call -> ((TransactionCallback<?>) call.getArgument(0)).doInTransaction(new SimpleTransactionStatus()));
DefaultAsyncTaskExecutorService executor = new DefaultAsyncTaskExecutorService(queue::add, progress, transaction);
executor.submitWithResult("hard:export:order", "订单", 1, reporter -> {
assertEquals(10L, TenantContextHolder.getTenantId());
assertEquals(1L, SecurityUtils.getUser().getId());
assertEquals("export-test-token", NonWebTokenContextHolder.getToken());
reporter.advance("已写入");
return AsyncTaskResult.file("/file/result", "结果.xlsx");
});
cleanup();
queue.get(0).run();
assertNull(TenantContextHolder.getTenantId()); assertNull(SecurityUtils.getAuthentication()); assertNull(NonWebTokenContextHolder.getToken());
verify(progress).start(initial.getRedisKey(), initial.getTaskId());
verify(progress).success(eq(initial.getRedisKey()), eq(initial.getTaskId()), anyString(), eq(AsyncTaskResult.file("/file/result", "结果.xlsx")));
}
@Test void rejectionAndQueryFailureCannotBecomeSuccessfulTasks() {
login(10, 1);
AsyncTaskProgressService progress = mock(AsyncTaskProgressService.class);
when(progress.create(anyString(), anyString(), anyInt())).thenReturn(initial());
TaskExecutor rejected = command -> { throw new TaskRejectedException("capacity"); };
DefaultAsyncTaskExecutorService executor = new DefaultAsyncTaskExecutorService(rejected, progress, mock(TransactionTemplate.class));
assertThrows(AsyncTaskException.class, () -> executor.submitWithResult("hard:export:order", "导出", 1, reporter -> AsyncTaskResult.empty()));
verify(progress).fail(anyString(), anyString(), eq("任务提交失败,请稍后重试"));
verify(progress, never()).success(anyString(), anyString(), anyString(), any());
reset(progress);
when(progress.create(anyString(), anyString(), anyInt())).thenReturn(initial());
List<Runnable> queue = new ArrayList<>();
TransactionTemplate transaction = mock(TransactionTemplate.class);
when(transaction.execute(any())).thenAnswer(call -> ((TransactionCallback<?>) call.getArgument(0)).doInTransaction(new SimpleTransactionStatus()));
DefaultAsyncTaskExecutorService failingExecutor = new DefaultAsyncTaskExecutorService(queue::add, progress, transaction);
failingExecutor.submitWithResult("hard:export:order", "导出", 1, reporter -> { throw new AsyncTaskException("没有可导出的数据"); });
queue.get(0).run();
verify(progress).fail(anyString(), anyString(), eq("没有可导出的数据"));
verify(progress, never()).success(anyString(), anyString(), anyString(), any());
assertNull(TenantContextHolder.getTenantId());
}
private AsyncTaskProgressVO initial() {
AsyncTaskProgressVO task = new AsyncTaskProgressVO();
task.setTaskId("a".repeat(32)); task.setRedisKey("hard:export:order"); task.setTaskName("导出");
return task;
}
}
@@ -0,0 +1,223 @@
package com.bsd.pjcloud.hard.service.export;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.alibaba.excel.write.metadata.fill.FillConfig;
import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate;
import com.bsd.pjcloud.common.excel.stream.TemplateBatchWorkbook;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.*;
import java.nio.file.*;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.atomic.*;
import java.util.regex.*;
import java.util.zip.ZipFile;
import javax.xml.stream.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/** 用旧 EasyExcel 填充结果作基准,验证实际模板、类型、样式及跨批次序号。 */
class BatchExportTemplateTest {
@TempDir Path directory;
private static final Pattern FIELD = Pattern.compile("\\{\\.([^{}]+)}");
@Test
void existingTemplatesKeepCellsTypesAndStylesAcrossBatches() throws Exception {
for (String name : List.of("个人消费明细.xlsx", "个人退款明细.xlsx", "退还各自食堂明细.xlsx",
"批量充值扣款流水明细.xlsx", "充值扣款发票申请.xlsx", "充值或扣款申请.xlsx",
"订单交易汇总.xlsx", "商户交易金额汇总.xlsx", "部门交易金额汇总.xlsx")) {
Path template = template(name);
List<Map<String, Object>> rows = rows(template, 201);
Map<String, Object> values = values(template);
Path oldFile = directory.resolve("old-" + name);
try (ExcelWriter writer = EasyExcel.write(oldFile.toFile()).withTemplate(template.toFile()).build()) {
WriteSheet sheet = EasyExcel.writerSheet().build();
writer.fill(values, sheet);
writer.fill(rows, FillConfig.builder().forceNewRow(true).build(), sheet);
}
Path result = directory.resolve("new-" + name);
AtomicInteger batches = new AtomicInteger();
TemplateBatchWorkbook.write(() -> Files.newInputStream(template), rows.size(), 200, values,
(page, size) -> slice(rows, page, size), row -> new HashMap<>(row),
(done, total) -> batches.incrementAndGet(), file -> { Files.copy(file, result); return null; }, workbook -> { });
assertEquals(2, batches.get(), name);
compare(oldFile, result, name);
}
}
@Test
void monthlyHorizontalLayoutKeepsTwentySevenColumnsAndFooter() throws Exception {
Path template = template("商户交易金额汇总(按月).xlsx");
List<Map<String, Object>> rows = rows(template, 201);
Map<String, Object> values = values(template);
values.put("title", "2026年所有合作商家流水情况统计表");
for (int i = 0; i < 26; i++) values.put("total" + i, i % 2 == 0 ? 12.34D : 2L);
Path output = directory.resolve("monthly.xlsx");
TemplateBatchWorkbook.write(() -> Files.newInputStream(template), rows.size(), 200, values,
(page, size) -> slice(rows, page, size), row -> new HashMap<>(row), null,
file -> { Files.copy(file, output); return null; }, workbook -> {
Row footer = workbook.getSheetAt(0).getRow(5);
CellStyle style = footer.getCell(1).getCellStyle();
for (int i = 0; i < 26; i++) {
Cell cell = footer.getCell(i + 1, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK);
cell.setCellStyle(style); cell.setCellValue("{total" + i + "}");
}
});
try (Workbook workbook = new XSSFWorkbook(Files.newInputStream(output))) {
Sheet sheet = workbook.getSheetAt(0);
assertEquals(27, sheet.getRow(3).getLastCellNum());
assertEquals("2026年所有合作商家流水情况统计表", sheet.getRow(0).getCell(0).getStringCellValue());
assertEquals("小计", sheet.getRow(205).getCell(0).getStringCellValue());
assertEquals(12.34, sheet.getRow(205).getCell(25).getNumericCellValue());
assertEquals(2, sheet.getRow(205).getCell(26).getNumericCellValue());
assertEquals(16, sheet.getNumMergedRegions());
}
}
@Test
void incompleteDataAndSheetOverflowNeverPublishAFile() throws Exception {
Path template = template("个人消费明细.xlsx");
AtomicBoolean consumed = new AtomicBoolean();
assertThrows(IllegalStateException.class, () -> TemplateBatchWorkbook.write(() -> Files.newInputStream(template),
201, 200, new HashMap<>(), (page, size) -> page == 1 ? rows(template, 199) : List.of(),
row -> new HashMap<>(row), null, file -> { consumed.set(true); return null; }, workbook -> { }));
assertFalse(consumed.get());
assertThrows(IllegalArgumentException.class, () -> TemplateBatchWorkbook.write(() -> Files.newInputStream(template),
1_048_576, 200, new HashMap<>(), (page, size) -> { fail("超过上限时不查询明细"); return List.<Map<String, Object>>of(); },
row -> row, null, file -> { consumed.set(true); return null; }, workbook -> { }));
assertFalse(consumed.get());
}
@Test
void uploadFailureRemovesCompleteTemporaryWorkbook() throws Exception {
Path template = template("个人消费明细.xlsx");
AtomicReference<Path> output = new AtomicReference<>();
assertThrows(IOException.class, () -> TemplateBatchWorkbook.write(() -> Files.newInputStream(template), 1, 200,
new HashMap<>(), (page, size) -> page == 1 ? rows(template, 1) : List.of(), row -> row, null,
file -> { output.set(file); throw new IOException("模拟上传失败"); }, workbook -> { }));
assertNotNull(output.get());
assertFalse(Files.exists(output.get()));
}
@Test
void hundredThousandAndMillionRowsWithBoundedHeap() throws Exception {
assumeTrue(Boolean.getBoolean("export.stress"), "通过 -Dexport.stress=true 启用大数据生成验证");
Path template = template("个人消费明细.xlsx");
for (long count : new long[]{100_000, 1_000_000}) {
AtomicInteger calls = new AtomicInteger();
AtomicLong completed = new AtomicLong();
long started = System.nanoTime();
long size = TemplateBatchWorkbook.write(() -> Files.newInputStream(template), count, 200,
new HashMap<>(Map.of("computeTotalPayPrice", "12345.67")), (page, batch) -> {
List<Map<String, Object>> data = new ArrayList<>();
long start = (page - 1) * batch;
for (long i = start; i < Math.min(start + batch, count); i++) {
data.add(new HashMap<>(Map.of("lineNumber", i + 1, "username", "用户" + i, "userMobile", "13800000000", "deptName", "部门", "bizType", "消费", "priceStr", "12.34", "createTime", LocalDateTime.of(2026, 10, 21, 12, 0))));
}
assertTrue(data.size() <= 200);
calls.incrementAndGet();
return data;
}, row -> row, (done, total) -> completed.set(done), file -> {
try (ZipFile zip = new ZipFile(file.toFile()); InputStream input = zip.getInputStream(zip.getEntry("xl/worksheets/sheet1.xml"))) {
XMLStreamReader xml = XMLInputFactory.newFactory().createXMLStreamReader(input);
long actualRows = 0;
while (xml.hasNext()) if (xml.next() == XMLStreamConstants.START_ELEMENT && "row".equals(xml.getLocalName())) actualRows++;
xml.close();
assertEquals(count + 3, actualRows);
}
return Files.size(file);
}, workbook -> { });
assertEquals(count, completed.get());
assertEquals(count / 200 + 1, calls.get());
System.out.println("EXPORT_STRESS rows=" + count + " bytes=" + size + " elapsedMs=" + (System.nanoTime() - started) / 1_000_000);
}
}
private static <T> List<T> slice(List<T> rows, long page, int size) {
int from = Math.toIntExact((page - 1) * size);
return from >= rows.size() ? List.of() : rows.subList(from, Math.min(from + size, rows.size()));
}
private static Path template(String name) {
Path root = Path.of(System.getProperty("user.dir")).toAbsolutePath();
while (root != null && !Files.isDirectory(root.resolve("pjcloud-wallet"))) root = root.getParent();
assertNotNull(root);
for (String module : List.of("wallet", "hardware")) {
Path file = root.resolve("pjcloud-" + module + "/pjcloud-" + module + "-biz/src/main/resources/file/" + name);
if (Files.exists(file)) return file;
}
throw new IllegalArgumentException(name);
}
private static List<Map<String, Object>> rows(Path template, int count) throws Exception {
Set<String> fields = new LinkedHashSet<>();
try (Workbook workbook = new XSSFWorkbook(Files.newInputStream(template))) {
for (Row row : workbook.getSheetAt(0)) for (Cell cell : row) {
if (cell.getCellType() != CellType.STRING) continue;
Matcher matcher = FIELD.matcher(cell.getStringCellValue());
while (matcher.find()) fields.add(matcher.group(1));
}
}
List<Map<String, Object>> rows = new ArrayList<>();
for (int i = 0; i < count; i++) {
Map<String, Object> data = new HashMap<>();
for (String field : fields) {
Object value = "值" + i;
if (field.equals("lineNumber")) value = i + 1L;
else if (field.equals("createTime") || field.equals("payTime")) value = LocalDateTime.of(2026, 10, 21, 10, 20, 30);
else if (field.toLowerCase(Locale.ROOT).contains("price") && !field.endsWith("Str")) value = 12.34D;
else if (field.toLowerCase(Locale.ROOT).contains("count") || field.endsWith("Cont")) value = 2L;
else if (field.equals("mchPlatformRanke")) value = 3D;
data.put(field, value);
}
rows.add(data);
}
return rows;
}
private static Map<String, Object> values(Path template) throws Exception {
Map<String, Object> values = new HashMap<>();
try (Workbook workbook = new XSSFWorkbook(Files.newInputStream(template))) {
for (Row row : workbook.getSheetAt(0)) for (Cell cell : row) {
if (cell.getCellType() != CellType.STRING) continue;
Matcher matcher = Pattern.compile("\\{([^.{}][^{}]*)}").matcher(cell.getStringCellValue());
while (matcher.find()) values.put(matcher.group(1), "123.45");
}
}
values.put("startTime", "2026-10-01 00:00:00"); values.put("timeSpan", " 到 "); values.put("endTime", "2026-10-21 23:59:59");
return values;
}
private static void compare(Path expected, Path actual, String name) throws Exception {
try (Workbook old = new XSSFWorkbook(Files.newInputStream(expected)); Workbook fresh = new XSSFWorkbook(Files.newInputStream(actual))) {
Sheet a = old.getSheetAt(0), b = fresh.getSheetAt(0);
assertEquals(a.getLastRowNum(), b.getLastRowNum(), name + " 最后行");
assertEquals(a.getNumMergedRegions(), b.getNumMergedRegions(), name + " 合并区域");
DataFormatter formatter = new DataFormatter();
for (int r = 0; r <= a.getLastRowNum(); r++) {
Row left = a.getRow(r), right = b.getRow(r);
if (left == null && right == null) continue;
for (int c = 0; c < Math.max(left == null ? 0 : left.getLastCellNum(), right == null ? 0 : right.getLastCellNum()); c++) {
Cell x = left == null ? null : left.getCell(c), y = right == null ? null : right.getCell(c);
String location = name + " R" + (r + 1) + "C" + (c + 1);
assertEquals(formatter.formatCellValue(x), formatter.formatCellValue(y), location + " 显示值");
if (x == null || y == null || (x.getCellType() == CellType.BLANK && y.getCellType() == CellType.BLANK)) continue;
assertEquals(x.getCellType(), y.getCellType(), location + " 类型");
CellStyle sx = x.getCellStyle(), sy = y.getCellStyle();
assertEquals(sx.getDataFormatString(), sy.getDataFormatString(), location + " 格式");
assertEquals(sx.getAlignment(), sy.getAlignment(), location + " 对齐");
assertEquals(sx.getBorderBottom(), sy.getBorderBottom(), location + " 边框");
assertEquals(sx.getFillForegroundColor(), sy.getFillForegroundColor(), location + " 填充色");
assertEquals(old.getFontAt(sx.getFontIndexAsInt()).getFontHeight(), fresh.getFontAt(sy.getFontIndexAsInt()).getFontHeight(), location + " 字号");
}
}
}
}
}
@@ -0,0 +1,83 @@
package com.bsd.pjcloud.hard.service.export;
import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.hard.api.dto.order.*;
import com.bsd.pjcloud.hard.api.vo.order.*;
import com.bsd.pjcloud.hard.service.HardOrderService;
import com.bsd.pjcloud.hard.service.order.HardOrderQuerySupport;
import com.bsd.pjcloud.hard.service.impl.order.*;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletResponse;
import java.io.ByteArrayInputStream;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** 对实际导出文件检查合计占位符,避免只验证公共模板而遗漏业务映射。 */
class HardBatchExportFileTest {
private final HardOrderService orders = mock(HardOrderService.class);
private final HardOrderTotalServiceImpl totals = mock(HardOrderTotalServiceImpl.class);
private final HardBatchExportService service = new HardBatchExportService(null, orders, mock(HardOrderQuerySupport.class), totals, null, null, null);
@Test void orderFooterAccumulatesAllPagesIntoOriginalTemplateKeys() throws Exception {
when(orders.scopePageList(any(), any(), any())).thenAnswer(call -> {
Page<HardOrderVo> request = call.getArgument(0);
List<HardOrderVo> rows = new ArrayList<>();
for (long i = (request.getCurrent() - 1) * request.getSize(); i < Math.min(request.getCurrent() * request.getSize(), 201); i++) {
rows.add(BeanUtil.toBean(Map.of("totalPrice", 123, "extraPrice", 20, "payPrice", 143, "merchantName", "食堂"), HardOrderVo.class));
}
return new Page<HardOrderVo>(request.getCurrent(), request.getSize()).setRecords(rows).setTotal(201);
});
MockHttpServletResponse response = new MockHttpServletResponse();
service.order(response, new HardOrderDto());
try (Workbook workbook = open(response)) {
Sheet sheet = workbook.getSheetAt(0);
assertEquals(201, sheet.getRow(202).getCell(0).getNumericCellValue());
assertEquals(1.23, sheet.getRow(2).getCell(7).getNumericCellValue());
assertEquals("247.23", sheet.getRow(203).getCell(7).getStringCellValue());
assertEquals("40.20", sheet.getRow(203).getCell(8).getStringCellValue());
assertEquals("287.43", sheet.getRow(203).getCell(9).getStringCellValue());
}
}
@Test void merchantCommissionAndMoneyFooterAreNotLostDuringMigration() throws Exception {
HardOrderCsVo row = BeanUtil.toBean(Map.of("totalPayPrice", 12345, "totalPayCount", 3, "totalRefundPrice", 123,
"totalRefundCount", 1, "totalProfitPrice", 12222, "totalProfitCount", 2, "mchPlatformRankePrice", 1222.2, "mchPlatformRanke", 10), HardOrderCsVo.class);
when(totals.listCsForExport(any(), isNull())).thenReturn(List.of(row));
MockHttpServletResponse response = new MockHttpServletResponse();
service.merchant(response, new HardOrderDto(), false);
try (Workbook workbook = open(response)) {
Sheet sheet = workbook.getSheetAt(0);
assertEquals("10.0%", sheet.getRow(2).getCell(9).getStringCellValue());
assertEquals("123.45", sheet.getRow(3).getCell(3).getStringCellValue());
assertEquals("1.23", sheet.getRow(3).getCell(5).getStringCellValue());
assertEquals("122.22", sheet.getRow(3).getCell(7).getStringCellValue());
assertEquals("12.22", sheet.getRow(3).getCell(10).getStringCellValue());
}
}
@Test void departmentFooterUsesExistingSixteenSummaryPlaceholders() throws Exception {
HardDeptTotalVo row = BeanUtil.toBean(Map.of("deptName", "部门", "walletPrice", 123, "walletOncePrice", 234,
"orderPayPrice", 345, "orderRefundPrice", 12, "orderProfitPrice", 333, "walletReturnCanteenPrice", 45), HardDeptTotalVo.class);
when(totals.listDeptForExport(any(), isNull())).thenReturn(List.of(row));
MockHttpServletResponse response = new MockHttpServletResponse();
service.department(response, new HardDeptTotalDto());
try (Workbook workbook = open(response)) {
Sheet sheet = workbook.getSheetAt(0);
assertEquals("1.23", sheet.getRow(3).getCell(2).getStringCellValue());
assertEquals("2.34", sheet.getRow(3).getCell(4).getStringCellValue());
assertEquals("0.45", sheet.getRow(3).getCell(10).getStringCellValue());
assertEquals("3.45", sheet.getRow(3).getCell(12).getStringCellValue());
assertEquals("0.12", sheet.getRow(3).getCell(14).getStringCellValue());
assertEquals("3.33", sheet.getRow(3).getCell(16).getStringCellValue());
}
}
private Workbook open(MockHttpServletResponse response) throws Exception {
return new XSSFWorkbook(new ByteArrayInputStream(response.getContentAsByteArray()));
}
}
@@ -0,0 +1,68 @@
package com.bsd.pjcloud.hard.service.export;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.hard.api.dto.order.*;
import com.bsd.pjcloud.hard.api.vo.order.*;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.mapper.order.model.HardOrderDepartmentStatisticsDetail;
import com.bsd.pjcloud.hard.service.impl.order.HardOrderTotalServiceImpl;
import com.bsd.pjcloud.wallet.api.vo.WalletTransactionDepartmentSnapshotVo;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** 验证分片边界、原始金额累计及历史部门归属,不依赖真实数据库。 */
class HardBatchExportQueryTest {
@Test void naturalDaySlicesKeepRawCentsAndCommissionIsRoundedAfterMerging() {
HardOrderTotalServiceImpl service = mock(HardOrderTotalServiceImpl.class, CALLS_REAL_METHODS);
HardOrderMapper mapper = mock(HardOrderMapper.class);
ReflectionTestUtils.setField(service, "hardOrderMapper", mapper);
DataScope scope = mock(DataScope.class);
List<LocalDateTime[]> ranges = new ArrayList<>();
when(mapper.listCs(any(), eq(scope))).thenAnswer(call -> {
HardOrderDto query = call.getArgument(0);
ranges.add(query.getPayTimeList());
HardOrderCsVo row = new HardOrderCsVo(); row.setMerchantId(1L);
row.setTotalPayPrice(new BigDecimal(ranges.size() == 1 ? "101" : "201"));
row.setTotalPayCount(BigDecimal.ONE);
return List.of(row);
});
HardOrderDto query = new HardOrderDto();
LocalDateTime from = LocalDateTime.of(2026, 10, 20, 10, 0), to = LocalDateTime.of(2026, 10, 21, 10, 0);
query.setPayTimeList(new LocalDateTime[]{from, to});
AsyncTaskProgressReporter reporter = mock(AsyncTaskProgressReporter.class);
List<HardOrderCsVo> rows = ReflectionTestUtils.invokeMethod(service, "queryCsExportSlices", query, false, scope, reporter);
assertEquals(2, ranges.size());
assertEquals(from, ranges.get(0)[0]); assertEquals(to, ranges.get(1)[1]);
assertEquals(ranges.get(1)[0].minusNanos(1), ranges.get(0)[1]);
assertEquals(new BigDecimal("302"), rows.get(0).getTotalPayPrice());
assertEquals(new BigDecimal("2"), rows.get(0).getTotalPayCount());
rows.get(0).setTotalProfitPrice(rows.get(0).getTotalPayPrice());
MchManageVo merchant = new MchManageVo(); merchant.setId(1L); merchant.setPlatformRanke(10D);
ReflectionTestUtils.invokeMethod(service, "computeMchPlatformRanke", rows, List.of(merchant));
assertEquals(new BigDecimal("30.20"), rows.get(0).getMchPlatformRankePrice());
verify(reporter, times(2)).advance(anyString());
}
@Test void departmentExportUsesWalletHistoryAndRetainsNonWalletFallback() {
HardOrderTotalServiceImpl service = mock(HardOrderTotalServiceImpl.class, CALLS_REAL_METHODS);
HardOrderDepartmentStatisticsDetail first = new HardOrderDepartmentStatisticsDetail();
first.setBizId("order-1"); first.setDeptId(7L); first.setSum(123L); first.setCount(1L);
HardOrderDepartmentStatisticsDetail cash = new HardOrderDepartmentStatisticsDetail();
cash.setBizId("order-2"); cash.setDeptId(7L); cash.setSum(200L); cash.setCount(1L);
WalletTransactionDepartmentSnapshotVo history = new WalletTransactionDepartmentSnapshotVo(); history.setDeptId(42L);
HardDeptTotalDto query = new HardDeptTotalDto(); query.setDeptIds(List.of(42L)); query.setIsInclude(true);
List<HardOrderSumAndCountVo> rows = ReflectionTestUtils.invokeMethod(service, "aggregatePaymentsByTransactionDepartment", List.of(first, cash), Map.of("order-1", history), query);
assertEquals(1, rows.size()); assertEquals(42L, rows.get(0).getDeptId()); assertEquals(123L, rows.get(0).getSum());
query.setDeptIds(List.of(7L));
rows = ReflectionTestUtils.invokeMethod(service, "aggregatePaymentsByTransactionDepartment", List.of(first, cash), Map.of("order-1", history), query);
assertEquals(1, rows.size()); assertEquals(7L, rows.get(0).getDeptId()); assertEquals(200L, rows.get(0).getSum());
}
}
@@ -0,0 +1,70 @@
package com.bsd.pjcloud.hard.service.impl.third;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardThirdOrderExcelVo;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class HardThirdOrderQuerySqlTest {
@Test
void selectedIdsAndAnyHistoricalRefundNumberAreAppliedToTheDatabaseQuery() throws Exception {
HardOrderDto query = new HardOrderDto();
query.setId(1L);
query.setOrderIds(List.of(1L, 2L));
query.setOrderType("6");
query.setAfterSaleNo("older-third-refund");
query.setThirdRefundState("PARTIAL");
String sql = sql(query);
assertTrue(sql.contains("a.id = ?"));
assertTrue(sql.contains("a.id in"));
assertTrue(sql.contains("a.refund_price > 0 and a.refund_price < a.pay_price"));
assertTrue(sql.contains("refund_filter.no = ?"));
assertTrue(sql.contains("refund_filter.tenant_id = a.tenant_id"));
assertFalse(sql.contains("and s.no = ?"), "历史退款单号不能只匹配最新一笔售后");
}
@Test
void fullAndProcessingRefundFiltersAreRenderedSeparately() throws Exception {
HardOrderDto query = new HardOrderDto();
query.setThirdRefundState("FULL");
assertTrue(sql(query).contains("a.refund_price >= a.pay_price"));
query.setThirdRefundState("PROCESSING");
assertTrue(sql(query).contains("a.refund_status = '10'"));
}
@Test
void exportKeepsLongIdsAndConvertsCentsWithoutRoundingLoss() {
HardOrderVo order = new HardOrderVo();
order.setId(900719925474099312L);
order.setPayPrice(1000);
order.setRefundPrice(123);
order.setOrderStatus("10");
order.setPayTime(LocalDateTime.now());
HardThirdOrderExcelVo row = HardThirdOrderExcelVo.from(order);
assertEquals("900719925474099312", row.getPlatformOrderId());
assertEquals(new BigDecimal("1.23"), row.getRefundPrice());
assertEquals(new BigDecimal("8.77"), row.getRemainingPrice());
assertEquals("已部分退款", row.getStatus());
}
private String sql(HardOrderDto query) throws Exception {
MybatisConfiguration configuration = new MybatisConfiguration();
String resource = "mapper/order/HardOrderMapper.xml";
try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) {
assertNotNull(input);
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
}
return configuration.getMappedStatement("com.bsd.pjcloud.hard.mapper.order.HardOrderMapper.scopePageList")
.getBoundSql(Map.of("dto", query)).getSql().replaceAll("\\s+", " ").trim();
}
}
@@ -0,0 +1,272 @@
package com.bsd.pjcloud.hard.service.impl.third;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.HardThirdOrderRefundDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleLogMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.*;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import jakarta.validation.Validation;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class HardThirdOrderServiceImplTest {
private HardOrderService orders;
private HardAfterSaleService afterSales;
private HardOrderMapper orderMapper;
private HardAfterSaleLogMapper logs;
private HardThirdOrderServiceImpl service;
private PjcloudUser user;
private HardOrderEntity order;
@BeforeEach
void setUp() {
orders = mock(HardOrderService.class);
afterSales = mock(HardAfterSaleService.class);
orderMapper = mock(HardOrderMapper.class);
logs = mock(HardAfterSaleLogMapper.class);
HardManagerService managers = mock(HardManagerService.class);
user = mock(PjcloudUser.class);
when(user.getId()).thenReturn(7L);
when(managers.getCurrentUserHardTypeInfo(user)).thenReturn(new CurrentUserHardTypeInfoDto());
service = new HardThirdOrderServiceImpl(orders, managers, mock(HardCarService.class),
afterSales, orderMapper, logs, mock(RemoteMchService.class));
HardOrderVo scoped = new HardOrderVo();
scoped.setId(1L);
when(orders.scopeList(any(HardOrderDto.class), any(DataScope.class))).thenReturn(List.of(scoped));
order = HardOrderEntity.builder().id(1L).orderNo("third-1").userId(2L)
.orderType("6").orderStatus("5").payType("3").walletId(3L)
.payPrice(1000).refundPrice(0).payTime(LocalDateTime.now()).build();
when(orderMapper.selectByIdForUpdate(1L)).thenReturn(order);
when(afterSales.list(any(Wrapper.class))).thenReturn(List.of());
when(afterSales.save(any(HardAfterSaleEntity.class))).thenAnswer(call -> {
call.<HardAfterSaleEntity>getArgument(0).setId(9L);
return true;
});
}
@Test
void partiallyRefundedOrderCanRefundRemainingAmountWithoutPlatformCart() {
order.setOrderStatus("10");
order.setRefundPrice(200);
when(afterSales.list(any(Wrapper.class))).thenReturn(List.of(completed("external-refund", 200)));
assertTrue(service.refund(request(800), user));
ArgumentCaptor<HardAfterSaleEntity> saved = ArgumentCaptor.forClass(HardAfterSaleEntity.class);
verify(afterSales).save(saved.capture());
assertEquals(800, saved.getValue().getRefundPrice());
assertEquals("AT08228fb74560be321343402cb01bf6", saved.getValue().getNo());
assertEquals("10", saved.getValue().getStatus());
verify(afterSales).refund(9L, "third_admin_request_number_123456");
verify(afterSales).updateById(argThat((HardAfterSaleEntity sale) ->
"50".equals(sale.getStatus()) && sale.getRefundTime() != null));
verify(logs).insert(any(com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleLogEntity.class));
}
@Test
void amountAboveRemainingIsRejectedBeforeSavingOrCrediting() {
order.setRefundPrice(200);
assertThrows(IllegalArgumentException.class, () -> service.refund(request(801), user));
verify(afterSales, never()).save(any());
verify(afterSales, never()).refund(anyLong(), anyString());
}
@Test
void completedRefundRequestIsIdempotentEvenAfterFullRefund() {
order.setOrderStatus("10");
order.setRefundPrice(1000);
when(afterSales.list(any(Wrapper.class))).thenReturn(List.of(completed("AT08228fb74560be321343402cb01bf6", 1000)));
assertTrue(service.refund(request(1000), user));
verify(afterSales, never()).save(any());
verify(afterSales, never()).refund(anyLong(), anyString());
}
@Test
void sameRequestCannotChangeAmount() {
when(afterSales.list(any(Wrapper.class))).thenReturn(List.of(completed("AT08228fb74560be321343402cb01bf6", 200)));
assertThrows(IllegalArgumentException.class, () -> service.refund(request(300), user));
verify(afterSales, never()).save(any());
}
@Test
void pendingRefundPreventsAnotherCredit() {
HardAfterSaleEntity pending = completed("pending-refund", 200);
pending.setStatus("10");
when(afterSales.list(any(Wrapper.class))).thenReturn(List.of(pending));
assertThrows(IllegalArgumentException.class, () -> service.refund(request(100), user));
verify(afterSales, never()).save(any());
}
@Test
void unpaidOrderCannotBeRefunded() {
order.setOrderStatus("1");
order.setPayTime(null);
assertThrows(IllegalArgumentException.class, () -> service.refund(request(100), user));
verify(afterSales, never()).save(any());
}
@Test
void outOfScopeOrderIsRejectedBeforeAcquiringLock() {
when(orders.scopeList(any(), any())).thenReturn(List.of());
assertThrows(IllegalArgumentException.class, () -> service.refund(request(100), user));
verify(orderMapper, never()).selectByIdForUpdate(anyLong());
verify(afterSales, never()).save(any());
}
@Test
void failedWalletCreditDoesNotMarkRefundComplete() {
doThrow(new IllegalStateException("wallet unavailable")).when(afterSales).refund(9L, "third_admin_request_number_123456");
assertThrows(IllegalStateException.class, () -> service.refund(request(100), user));
verify(afterSales, never()).updateById(any(HardAfterSaleEntity.class));
verifyZeroInteractions(logs);
}
@Test
void pageCannotRequestAnotherOrderType() {
HardOrderDto query = new HardOrderDto();
query.setOrderType("1");
service.page(new Page<>(), query, user);
ArgumentCaptor<HardOrderDto> captured = ArgumentCaptor.forClass(HardOrderDto.class);
verify(orders).scopePageList(any(), captured.capture(), any());
assertEquals("6", captured.getValue().getOrderType());
}
@Test
void exportUsesTheSameMerchantScopeAndSelectedOrderIds() {
HardOrderDto query = new HardOrderDto();
query.setOrderType("1");
query.setOrderIds(List.of(1L));
service.list(query, user);
ArgumentCaptor<HardOrderDto> captured = ArgumentCaptor.forClass(HardOrderDto.class);
ArgumentCaptor<DataScope> scope = ArgumentCaptor.forClass(DataScope.class);
verify(orders).scopeList(captured.capture(), scope.capture());
assertEquals("6", captured.getValue().getOrderType());
assertEquals(List.of(1L), captured.getValue().getOrderIds());
assertEquals("merchant_dept_id", scope.getValue().getScopeDeptName());
}
@Test
void legacyShortRequestRemainsIdempotentAfterTheNumberFormatChanges() {
HardThirdOrderRefundDto dto = request(200);
dto.setRequestNo("legacy_request_01");
when(afterSales.list(any(Wrapper.class))).thenReturn(List.of(completed("admin_third_legacy_request_01", 200)));
assertTrue(service.refund(dto, user));
verify(afterSales, never()).save(any());
verify(afterSales, never()).refund(anyLong(), anyString());
}
@Test
void differentRequestSuffixesDoNotCollapseToTheSameRefundNumber() {
HardThirdOrderRefundDto first = request(100);
HardThirdOrderRefundDto second = request(100);
first.setRequestNo("A".repeat(39) + "0");
second.setRequestNo("A".repeat(39) + "1");
service.refund(first, user);
service.refund(second, user);
ArgumentCaptor<HardAfterSaleEntity> saved = ArgumentCaptor.forClass(HardAfterSaleEntity.class);
verify(afterSales, times(2)).save(saved.capture());
assertEquals(32, saved.getAllValues().get(0).getNo().length());
assertEquals(32, saved.getAllValues().get(1).getNo().length());
assertNotEquals(saved.getAllValues().get(0).getNo(), saved.getAllValues().get(1).getNo());
}
@Test
void generatedNumbersInsertIntoTheRealAfterSaleSchemaForAllRequestLengths() throws Exception {
// 读取仓库真实 DDL,在 H2 MySQL 模式下保留 varchar(32)/varchar(255) 约束。
try (Connection connection = DriverManager.getConnection("jdbc:h2:mem:third_refund_" + UUID.randomUUID() + ";MODE=MySQL")) {
Path root = Path.of(getClass().getProtectionDomain().getCodeSource().getLocation().toURI());
while (root != null && !Files.isRegularFile(root.resolve("db/800pjcloudx_hardware.sql"))) root = root.getParent();
assertNotNull(root, "必须使用仓库实际售后表 DDL 验证字段长度");
String schema = Files.readString(root.resolve("db/800pjcloudx_hardware.sql"));
int start = schema.indexOf("CREATE TABLE hard_after_sale\n");
int end = schema.indexOf(") ENGINE", start);
assertTrue(start >= 0 && end > start);
String ddl = schema.substring(start, end + 1).replaceAll("(?i)\\s+COLLATE\\s+\\w+", "");
try (var statement = connection.createStatement()) { statement.execute(ddl); }
// 复现旧版默认请求号拼接 36 位而插入失败,确保测试能抓住这次真实错误。
assertThrows(SQLException.class, () -> insertAfterSale(connection,
HardAfterSaleEntity.builder().id(100L).no("admin_third_" + "N".repeat(24)).build()));
AtomicLong nextId = new AtomicLong(8);
when(afterSales.save(any(HardAfterSaleEntity.class))).thenAnswer(call -> {
HardAfterSaleEntity sale = call.getArgument(0);
sale.setId(nextId.incrementAndGet());
insertAfterSale(connection, sale);
return true;
});
for (int length : List.of(16, 24, 40)) {
HardThirdOrderRefundDto dto = request(100);
dto.setRequestNo("N".repeat(length));
dto.setApplyDescription("补".repeat(255));
assertTrue(service.refund(dto, user));
}
try (var statement = connection.createStatement(); var rows = statement.executeQuery("select no, apply_description from hard_after_sale")) {
int count = 0;
while (rows.next()) {
assertEquals(32, rows.getString("no").length());
assertEquals(255, rows.getString("apply_description").length());
count++;
}
assertEquals(3, count);
}
}
}
@Test
void descriptionIsRejectedBeforeItExceedsTheDatabaseColumn() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
HardThirdOrderRefundDto dto = request(100);
dto.setApplyDescription("补".repeat(255));
assertTrue(validator.validate(dto).isEmpty());
dto.setApplyDescription("补".repeat(256));
assertTrue(validator.validate(dto).stream().anyMatch(violation -> "applyDescription".equals(violation.getPropertyPath().toString())));
}
}
private void insertAfterSale(Connection connection, HardAfterSaleEntity sale) throws SQLException {
try (PreparedStatement insert = connection.prepareStatement("insert into hard_after_sale "
+ "(id, no, way, user_id, apply_reason, apply_description, order_id, refund_price) values (?, ?, '10', 2, 'test', ?, 1, 100)")) {
insert.setLong(1, sale.getId());
insert.setString(2, sale.getNo());
insert.setString(3, sale.getApplyDescription());
insert.executeUpdate();
}
}
private HardThirdOrderRefundDto request(int price) {
HardThirdOrderRefundDto dto = new HardThirdOrderRefundDto();
dto.setOrderId(1L);
dto.setRefundPrice(price);
dto.setRequestNo("request_number_123456");
dto.setApplyReason("商品退款");
return dto;
}
private HardAfterSaleEntity completed(String no, int price) {
return HardAfterSaleEntity.builder().id(8L).orderId(1L).no(no).status("50").refundPrice(price).build();
}
}
@@ -0,0 +1,63 @@
package com.bsd.pjcloud.hard.service.impl.third;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.config.ThirdAppCallbackConfig;
import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleLogMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.*;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaySoureBizTypeEnum;
import com.bsd.pjcloud.thirdparty.dto.pay.PayDetailDto;
import com.bsd.pjcloud.thirdparty.dto.pay.WaterDto;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class HardThirdPayQueryTest {
@Test
void externalDetailAndWaterQueriesWorkBeforeAnyRefund() {
HardOrderService orders = mock(HardOrderService.class);
HardAfterSaleService refunds = mock(HardAfterSaleService.class);
ThirdAppCallbackConfig config = new ThirdAppCallbackConfig();
config.setLbMchId(100L);
RemoteMchService merchants = mock(RemoteMchService.class);
MchManageVo merchant = new MchManageVo();
merchant.setId(100L);
when(merchants.getById(100L)).thenReturn(R.ok(merchant));
HardThirdPayServiceImpl service = new HardThirdPayServiceImpl(config, orders,
mock(HardCarService.class), refunds, mock(HardAfterSaleLogMapper.class), mock(HardOrderMapper.class),
merchants, mock(RemoteUserService.class), mock(RemoteWalletService.class));
HardOrderVo order = new HardOrderVo();
order.setId(1L);
order.setOrderNo("third-1");
order.setPayPrice(1000);
when(refunds.list(any(Wrapper.class))).thenReturn(List.of());
when(orders.scopeList(any(), any())).thenReturn(List.of(order));
Page<HardOrderVo> page = new Page<>();
page.setRecords(List.of(order));
when(orders.scopePageList(any(), any(), any())).thenReturn(page);
PayDetailDto detailDto = new PayDetailDto();
detailDto.setThirdOrderId("third-1");
var detail = service.orderDetail(detailDto, ThirdPaySoureBizTypeEnum.LB);
assertEquals(200, detail.getCode());
assertNotNull(detail.getData().getRefundList());
assertTrue(detail.getData().getRefundList().isEmpty());
assertEquals(0, detail.getData().getRefundTotalPrice());
WaterDto waterDto = new WaterDto();
waterDto.setCurrent(1L);
waterDto.setSize(10L);
var water = service.waterList(waterDto, ThirdPaySoureBizTypeEnum.LB);
assertEquals(200, water.getCode());
assertEquals(0, water.getData().get(0).getRefundTotalPrice());
}
}
@@ -0,0 +1,190 @@
package com.bsd.pjcloud.hard.service.impl.third;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.config.ThirdAppCallbackConfig;
import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleLogMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardAfterSaleService;
import com.bsd.pjcloud.hard.service.HardCarService;
import com.bsd.pjcloud.hard.service.HardOrderService;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaySoureBizTypeEnum;
import com.bsd.pjcloud.thirdparty.dto.pay.PayDto;
import com.bsd.pjcloud.thirdparty.dto.pay.WaterDto;
import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.scripting.defaults.DefaultParameterHandler;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.mockito.ArgumentCaptor;
import java.io.StringReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.DriverManager;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class HardThirdPaySourceTest {
private static final String NAMESPACE = "com.bsd.pjcloud.hard.mapper.order.HardOrderMapper";
@ParameterizedTest
@EnumSource(ThirdPaySoureBizTypeEnum.class)
void waterSqlOnlyReturnsOrdersFromTheVerifiedSource(ThirdPaySoureBizTypeEnum source) throws Exception {
Fixture fixture = new Fixture();
WaterDto dto = new WaterDto();
dto.setCurrent(1L);
dto.setSize(10L);
when(fixture.orders.scopePageList(any(), any(), isNull())).thenReturn(new Page<>());
assertEquals(200, fixture.service.waterList(dto, source).getCode());
ArgumentCaptor<HardOrderDto> captured = ArgumentCaptor.forClass(HardOrderDto.class);
verify(fixture.orders).scopePageList(any(), captured.capture(), isNull());
HardOrderDto query = captured.getValue();
assertEquals(HardOrderTypeEnum.THIRD_APP.getType(), query.getOrderType());
long merchantId = source == ThirdPaySoureBizTypeEnum.LB ? 100L : 200L;
assertEquals(merchantId, query.getMerchantId());
assertNull(query.getOrderSourceTypeId());
verify(fixture.merchants).getById(merchantId);
MybatisConfiguration configuration = new MybatisConfiguration();
Path mapper = Path.of("src/main/resources/mapper/order/HardOrderMapper.xml");
try (var input = Files.newInputStream(mapper)) {
new XMLMapperBuilder(input, configuration, mapper.toString(), configuration.getSqlFragments()).parse();
}
Map<String, Object> parameters = Map.of("dto", query);
BoundSql pageSql = configuration.getMappedStatement(NAMESPACE + ".scopePageList").getBoundSql(parameters);
assertTrue(pageSql.getSql().contains("a.merchant_id = ?"));
assertTrue(pageSql.getParameterMappings().stream().anyMatch(p -> p.getProperty().equals("dto.merchantId")));
assertFalse(pageSql.getParameterMappings().stream().anyMatch(p -> p.getProperty().equals("dto.orderSourceTypeId")));
String probe = """
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="waterSourceCheck">
<select id="query" resultType="long">
select a.id from hard_order a where
<include refid="com.bsd.pjcloud.hard.mapper.order.HardOrderMapper.whereA"/>
order by a.id
</select>
</mapper>
""";
new XMLMapperBuilder(new StringReader(probe), configuration, "water-source-check", configuration.getSqlFragments()).parse();
MappedStatement statement = configuration.getMappedStatement("waterSourceCheck.query");
BoundSql sql = statement.getBoundSql(parameters);
try (var connection = DriverManager.getConnection("jdbc:h2:mem:water_source_" + source.name() + ";MODE=MySQL");
var seed = connection.createStatement()) {
seed.execute("create table hard_order(id bigint, order_type varchar(8), merchant_id bigint, order_source_type_id bigint, del_flag varchar(1))");
seed.execute("insert into hard_order values (1,'6',100,null,'0'),(2,'6',200,null,'0'),(3,'6',300,null,'0'),(4,'1',100,null,'0'),(5,'6',200,null,'1')");
try (var prepared = connection.prepareStatement(sql.getSql())) {
new DefaultParameterHandler(statement, parameters, sql).setParameters(prepared);
try (var rows = prepared.executeQuery()) {
assertTrue(rows.next());
assertEquals(source == ThirdPaySoureBizTypeEnum.LB ? 1L : 2L, rows.getLong(1));
assertFalse(rows.next(), "Only the configured merchant's third-party orders may be returned");
}
}
}
}
@Test
void missingSourceNeverQueriesOrders() {
Fixture fixture = new Fixture();
assertEquals(500, fixture.service.waterList(new WaterDto(), null).getCode());
verify(fixture.orders, never()).scopePageList(any(), any(), any());
}
@ParameterizedTest
@EnumSource(ThirdPaySoureBizTypeEnum.class)
void unconfiguredMerchantNeverQueriesOrders(ThirdPaySoureBizTypeEnum source) {
Fixture fixture = new Fixture();
fixture.config.setLbMchId(null);
fixture.config.setNffcpMchId(null);
assertEquals(503, fixture.service.waterList(new WaterDto(), source).getCode());
verify(fixture.merchants, never()).getById(anyLong());
verify(fixture.orders, never()).scopePageList(any(), any(), any());
}
@ParameterizedTest
@EnumSource(ThirdPaySoureBizTypeEnum.class)
void missingMerchantNeverQueriesOrders(ThirdPaySoureBizTypeEnum source) {
Fixture fixture = new Fixture();
when(fixture.merchants.getById(anyLong())).thenReturn(R.ok((MchManageVo) null));
assertEquals(503, fixture.service.waterList(new WaterDto(), source).getCode());
verify(fixture.orders, never()).scopePageList(any(), any(), any());
}
@ParameterizedTest
@EnumSource(ThirdPaySoureBizTypeEnum.class)
void merchantWithoutIdNeverQueriesOrders(ThirdPaySoureBizTypeEnum source) {
Fixture fixture = new Fixture();
when(fixture.merchants.getById(anyLong())).thenReturn(R.ok(new MchManageVo()));
assertEquals(503, fixture.service.waterList(new WaterDto(), source).getCode());
verify(fixture.orders, never()).scopePageList(any(), any(), any());
}
@ParameterizedTest
@EnumSource(ThirdPaySoureBizTypeEnum.class)
void paymentAndWaterUseTheSameSourceMerchant(ThirdPaySoureBizTypeEnum source) {
Fixture fixture = new Fixture();
SysUser user = new SysUser();
user.setDeptId(1L);
UserInfo info = new UserInfo();
info.setSysUser(user);
when(fixture.users.getInnerByUserId(1L)).thenReturn(R.ok(info));
WalletManageEntity wallet = new WalletManageEntity();
wallet.setId(2L);
wallet.setBalance(10000);
when(fixture.wallets.getWallet(1L, WalletTypeEnum.PAY)).thenReturn(R.ok(wallet));
when(fixture.orders.save(any(HardOrderEntity.class))).thenAnswer(call -> {
call.<HardOrderEntity>getArgument(0).setId(3L);
return true;
});
PayDto dto = new PayDto();
dto.setUserId(1L);
dto.setPrice(100);
dto.setThirdOrderId("source-check-" + source.name());
assertEquals(200, fixture.service.walletPay(dto, WalletTypeEnum.PAY, source).getCode());
ArgumentCaptor<HardOrderEntity> saved = ArgumentCaptor.forClass(HardOrderEntity.class);
verify(fixture.orders).save(saved.capture());
assertEquals(source == ThirdPaySoureBizTypeEnum.LB ? 100L : 200L, saved.getValue().getMerchantId());
assertNull(saved.getValue().getOrderSourceTypeId());
verify(fixture.orders).fastReduceBalance(saved.getValue());
}
private static class Fixture {
final ThirdAppCallbackConfig config = new ThirdAppCallbackConfig();
final HardOrderService orders = mock(HardOrderService.class);
final RemoteMchService merchants = mock(RemoteMchService.class);
final RemoteUserService users = mock(RemoteUserService.class);
final RemoteWalletService wallets = mock(RemoteWalletService.class);
final HardThirdPayServiceImpl service;
Fixture() {
config.setLbMchId(100L);
config.setNffcpMchId(200L);
when(merchants.getById(anyLong())).thenAnswer(call -> {
MchManageVo merchant = new MchManageVo();
merchant.setId(call.getArgument(0));
return R.ok(merchant);
});
service = new HardThirdPayServiceImpl(config, orders, mock(HardCarService.class),
mock(HardAfterSaleService.class), mock(HardAfterSaleLogMapper.class), mock(HardOrderMapper.class),
merchants, users, wallets);
}
}
}
@@ -0,0 +1,78 @@
package com.bsd.pjcloud.hard.service.order;
import com.bsd.pjcloud.common.core.jackson.PjcloudJavaTimeModule;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderStatisticsVo;
import com.bsd.pjcloud.hard.controller.order.HardOrderController;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
class HardOrderStatisticsControllerTest {
@Mock private HardOrderStatisticsService service;
@InjectMocks private HardOrderController controller;
private MockMvc mvc;
private static final String QUERY = """
{"createTimeList":["2026-07-21 00:00:00","2026-10-21 23:59:59"],
"hasExtraPrice":false,"userDeptIds":[10],"isInclude":true,"orderType":"1"}
""";
@BeforeEach
void setup() {
MockitoAnnotations.initMocks(this);
ObjectMapper mapper = new ObjectMapper().registerModule(new PjcloudJavaTimeModule());
mvc = MockMvcBuilders.standaloneSetup(controller)
.setMessageConverters(new MappingJackson2HttpMessageConverter(mapper)).build();
}
@Test
void overviewAcceptsListFiltersAndReturnsExactCentStrings() throws Exception {
var result = new HardOrderStatisticsVo();
result.getSummary().setPayPrice(new BigDecimal("900719925474099301"));
result.setGeneratedAt(LocalDateTime.of(2026, 10, 21, 12, 0));
when(service.overview(any())).thenReturn(result);
mvc.perform(post("/hard-order/statistics").contentType(MediaType.APPLICATION_JSON).content(QUERY))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.summary.payPrice").value("900719925474099301"))
.andExpect(jsonPath("$.data.summary.orderCount").value("0"))
.andExpect(jsonPath("$.data.daily").isArray());
var query = ArgumentCaptor.forClass(HardOrderDto.class);
verify(service).overview(query.capture());
assertFalse(query.getValue().getHasExtraPrice());
assertEquals(List.of(10L), query.getValue().getUserDeptIds());
assertEquals(LocalDateTime.of(2026, 7, 21, 0, 0), query.getValue().getCreateTimeList()[0]);
assertTrue(HardOrderController.class.getMethod("statistics", HardOrderDto.class)
.getAnnotation(PreAuthorize.class).value().contains("hard_hardwareOrder_view"));
}
@Test
void merchantEndpointUsesDefaultPageAndExplicitHasMore() throws Exception {
when(service.merchants(any(), eq(1), eq(20))).thenReturn(new HardOrderStatisticsVo.MerchantPage(
List.of(), 1, 20, false, LocalDateTime.of(2026, 10, 21, 12, 0)));
mvc.perform(post("/hard-order/statistics/merchants").contentType(MediaType.APPLICATION_JSON).content(QUERY))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.current").value(1))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasMore").value(false));
verify(service).merchants(any(), eq(1), eq(20));
}
}
@@ -0,0 +1,114 @@
package com.bsd.pjcloud.hard.service.order;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.entity.SysRole;
import com.bsd.pjcloud.admin.api.feign.RemoteDataScopeService;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.*;
class HardOrderStatisticsScopeTest {
private final RemoteDataScopeService remote = mock(RemoteDataScopeService.class);
private final HardOrderStatisticsScopeResolver resolver = new HardOrderStatisticsScopeResolver(remote);
@BeforeEach
void setup() {
TenantContextHolder.setTenantId(7L);
var user = new PjcloudUser(1L, "alice", 10L, null, null, null, null, null, 7L, "test",
true, true, "0", true, true, List.of(new SimpleGrantedAuthority("ROLE_1")));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, "", user.getAuthorities()));
}
@AfterEach
void clear() {
TenantContextHolder.clear();
SecurityContextHolder.clearContext();
}
private SysRole role(int type) {
var role = new SysRole();
role.setDsType(type);
return role;
}
@Test
void fullAccessIsResolvedByTheExistingRoleHandler() {
when(remote.getRoleList(anyList())).thenReturn(R.ok(List.of(role(0))));
var scope = resolver.resolve();
assertTrue(scope.all());
assertEquals(7L, scope.tenantId());
}
@Test
void ownAndSelfRolesProduceAUnionWithoutBroadeningOtherFilters() {
when(remote.getRoleList(anyList())).thenReturn(R.ok(List.of(role(3), role(4))));
var scope = resolver.resolve();
assertFalse(scope.all());
assertEquals(List.of(10L), scope.deptIds());
assertEquals("alice", scope.username());
}
@Test
void descendantAndCustomDepartmentsAreDeduplicated() {
var custom = role(1);
custom.setDsScope("10,11");
var department = new SysDept();
department.setDeptId(11L);
when(remote.getRoleList(anyList())).thenReturn(R.ok(List.of(custom, role(2))));
when(remote.getDescendantList(10L)).thenReturn(R.ok(List.of(department)));
assertEquals(List.of(10L, 11L), resolver.resolve().deptIds());
}
@Test
void noRoleDataProducesAnEmptyPermissionScope() {
when(remote.getRoleList(anyList())).thenReturn(R.ok(List.of()));
var scope = resolver.resolve();
assertFalse(scope.all());
assertTrue(scope.deptIds().isEmpty());
assertNull(scope.username());
}
@Test
void missingTenantIsRejectedBeforeRemoteCalls() {
TenantContextHolder.clear();
assertThrows(IllegalArgumentException.class, resolver::resolve);
verifyNoMoreInteractions(remote);
}
@Test
void sharedPreparationReplacesClientAuthorizationAndCopiesImmutableMerchantIds() {
var manager = mock(HardManagerService.class);
var merchants = mock(RemoteMchService.class);
var allowed = new CurrentUserHardTypeInfoDto();
allowed.setCanteenView(true);
when(manager.getCurrentUserHardTypeInfo(any())).thenReturn(allowed);
var child = new MchManageVo();
child.setId(200L);
when(merchants.getByParentId(100L)).thenReturn(R.ok(List.of(child)));
var original = List.of(100L);
var dto = new HardOrderDto();
dto.setMchIds(original);
dto.setMchTypeCodes(List.of("untrusted"));
new HardOrderQuerySupport(manager, merchants).prepare(dto);
assertEquals(List.of(100L, 200L), dto.getMchIds());
assertEquals(List.of(100L), original);
assertEquals(allowed.getHardTypeList(), dto.getMchTypeCodes());
}
}
@@ -0,0 +1,133 @@
package com.bsd.pjcloud.hard.service.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.HardOrderStatisticsMapper;
import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsRow;
import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsScope;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.QueryTimeoutException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class HardOrderStatisticsServiceTest {
private final HardOrderStatisticsMapper mapper = mock(HardOrderStatisticsMapper.class);
private final HardOrderQuerySupport querySupport = mock(HardOrderQuerySupport.class);
private final HardOrderStatisticsScopeResolver scopeResolver = mock(HardOrderStatisticsScopeResolver.class);
private final HardOrderStatisticsService service = new HardOrderStatisticsService(mapper, querySupport, scopeResolver);
@BeforeEach
void setup() {
when(scopeResolver.resolve()).thenReturn(new HardOrderStatisticsScope(7L, true, List.of(), null));
}
static HardOrderDto query() {
HardOrderDto dto = new HardOrderDto();
dto.setCreateTimeList(new LocalDateTime[]{LocalDateTime.of(2026, 7, 21, 0, 0),
LocalDateTime.of(2026, 10, 21, 23, 59, 59)});
return dto;
}
@Test
void overviewIncludesUndatedOrdersAndAccumulatesWithoutIntegerOrJavascriptPrecisionLoss() throws Exception {
var day = new HardOrderStatisticsRow();
day.setDate(LocalDate.of(2026, 10, 21));
day.setPayType("1");
day.setOrderCount(3L);
day.setPayPrice(new BigDecimal("900719925474099301"));
day.setRefundPrice(new BigDecimal("201"));
var undated = new HardOrderStatisticsRow();
undated.setPayType("1");
undated.setOrderCount(2L);
undated.setPayPrice(new BigDecimal("99"));
when(mapper.overview(any(), any())).thenReturn(List.of(day, undated));
var result = service.overview(query());
assertEquals(5L, result.getSummary().getOrderCount());
assertEquals(1, result.getDaily().size());
assertEquals(2L, result.getWithoutPayTime().getOrderCount());
assertEquals(1, result.getPayTypes().size());
assertEquals(new BigDecimal("900719925474099400"), result.getSummary().getPayPrice());
assertEquals(new BigDecimal("900719925474099199"), result.getSummary().getNetPrice());
String json = new ObjectMapper().writeValueAsString(result.getSummary());
assertTrue(json.contains("\"payPrice\":\"900719925474099400\""));
assertTrue(json.contains("\"orderCount\":\"5\""));
verify(mapper, times(1)).overview(any(), any());
verifyNoMoreInteractions(mapper);
}
@Test
void emptyOverviewHasZeroTotalsAndNoCachedResult() {
when(mapper.overview(any(), any())).thenReturn(List.of());
assertEquals(BigDecimal.ZERO, service.overview(query()).getSummary().getNetPrice());
assertTrue(service.overview(query()).getDaily().isEmpty());
verify(mapper, times(2)).overview(any(), any());
}
@Test
void invalidRangesNeverReachPermissionOrDatabaseQueries() {
HardOrderDto dto = new HardOrderDto();
assertThrows(IllegalArgumentException.class, () -> service.overview(dto));
dto.setCreateTimeList(new LocalDateTime[]{LocalDateTime.of(2026, 7, 21, 0, 0), null});
assertThrows(IllegalArgumentException.class, () -> service.overview(dto));
dto.setCreateTimeList(new LocalDateTime[]{LocalDateTime.of(2026, 7, 21, 0, 0), LocalDateTime.of(2026, 7, 20, 0, 0)});
assertThrows(IllegalArgumentException.class, () -> service.overview(dto));
dto.setCreateTimeList(new LocalDateTime[]{LocalDateTime.of(2026, 7, 21, 0, 0), LocalDateTime.of(2026, 10, 22, 0, 0)});
assertThrows(IllegalArgumentException.class, () -> service.overview(dto));
verifyNoMoreInteractions(mapper, querySupport, scopeResolver);
}
@Test
void bothRangesMustBeValidAndRemainAnIntersection() {
HardOrderDto dto = query();
var payRange = new LocalDateTime[]{LocalDateTime.of(2026, 10, 1, 0, 0), LocalDateTime.of(2026, 10, 21, 23, 59)};
dto.setPayTimeList(payRange);
assertDoesNotThrow(() -> HardOrderStatisticsService.validateRange(dto));
assertSame(payRange, dto.getPayTimeList());
dto.setPayTimeList(new LocalDateTime[]{payRange[0], payRange[0].plusMonths(3).plusDays(1)});
assertThrows(IllegalArgumentException.class, () -> HardOrderStatisticsService.validateRange(dto));
}
@Test
void payTimeOnlyAndMonthEndBoundariesAreAccepted() {
HardOrderDto dto = new HardOrderDto();
dto.setPayTimeList(new LocalDateTime[]{LocalDateTime.of(2026, 1, 31, 8, 0), LocalDateTime.of(2026, 4, 30, 23, 59, 59)});
assertDoesNotThrow(() -> HardOrderStatisticsService.validateRange(dto));
}
@Test
void merchantPaginationUsesOneExtraRowWithoutCountQueries() {
when(mapper.merchants(any(), any(), eq(20L), eq(21)))
.thenReturn(java.util.Collections.nCopies(21, new HardOrderStatisticsVo.Merchant()));
var result = service.merchants(query(), 2, 20);
assertEquals(20, result.records().size());
assertTrue(result.hasMore());
assertEquals(2, result.current());
verify(mapper).merchants(any(), any(), eq(20L), eq(21));
verifyNoMoreInteractions(mapper);
}
@Test
void merchantPageBoundsAreValidated() {
assertThrows(IllegalArgumentException.class, () -> service.merchants(query(), 0, 20));
assertThrows(IllegalArgumentException.class, () -> service.merchants(query(), 1, 101));
verifyNoMoreInteractions(mapper);
}
@Test
void databaseTimeoutAsksForANarrowerRange() {
when(mapper.overview(any(), any())).thenThrow(new QueryTimeoutException("timeout"));
var exception = assertThrows(IllegalArgumentException.class, () -> service.overview(query()));
assertEquals("统计查询超时,请缩小查询范围后重试", exception.getMessage());
}
}
@@ -0,0 +1,318 @@
package com.bsd.pjcloud.hard.service.order;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.datascope.DataScopeInnerInterceptor;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderStatisticsVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderStatisticsMapper;
import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsScope;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.LocalCacheScope;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import java.io.InputStream;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 使用真实 Mapper、租户/数据权限插件和 SQL 执行器,核对列表与统计的筛选及关联基数。 */
class HardOrderStatisticsSqlTest {
private SqlSession session;
private Connection connection;
private MybatisConfiguration configuration;
private HardOrderMapper listMapper;
private HardOrderStatisticsMapper statisticsMapper;
private final HardOrderQuerySupport support = mock(HardOrderQuerySupport.class);
private final HardOrderStatisticsScopeResolver scopeResolver = mock(HardOrderStatisticsScopeResolver.class);
private HardOrderStatisticsService service;
private boolean allAccess = true;
private List<Long> departments = List.of();
private String username;
@BeforeEach
void setup() throws Exception {
var source = new UnpooledDataSource("org.h2.Driver",
"jdbc:h2:mem:statistics_" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE", "sa", "");
configuration = new MybatisConfiguration();
configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), source));
configuration.setMapUnderscoreToCamelCase(true);
configuration.setLocalCacheScope(LocalCacheScope.STATEMENT);
var interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override public Expression getTenantId() { return new LongValue(7L); }
}));
var dataScope = new DataScopeInnerInterceptor();
dataScope.setDataScopeHandle(scope -> allAccess);
interceptor.addInnerInterceptor(dataScope);
configuration.addInterceptor(interceptor);
for (String resource : List.of("mapper/order/HardOrderMapper.xml", "mapper/order/HardOrderStatisticsMapper.xml")) {
try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) {
assertNotNull(input);
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
}
}
session = new SqlSessionFactoryBuilder().build(configuration).openSession(true);
connection = session.getConnection();
createTables();
seed();
listMapper = session.getMapper(HardOrderMapper.class);
statisticsMapper = session.getMapper(HardOrderStatisticsMapper.class);
when(scopeResolver.resolve()).thenAnswer(invocation -> new HardOrderStatisticsScope(7L, allAccess, departments, username));
service = new HardOrderStatisticsService(statisticsMapper, support, scopeResolver);
}
@AfterEach
void close() {
if (session != null) session.close();
}
@Test
void allSearchFieldsAndBothDateRangesMatchTheList() {
List<Consumer<HardOrderDto>> cases = List.of(
q -> {}, q -> q.setUserId(1L), q -> q.setMerchantType("2"), q -> q.setMerchantId(200L),
q -> q.setHardTypeCode("2"), q -> q.setUserDeptId(30L), q -> q.setHourType("2"),
q -> q.setOrderPayTypeCodes(List.of("2")), q -> q.setHasExtraPrice(true), q -> q.setHasExtraPrice(false),
q -> q.setOrderStatus("20"), q -> q.setOrderType("2"), q -> q.setWalletTypeIdList(List.of(111L)),
q -> q.setUserDeptIds(List.of(20L)), q -> { q.setUserDeptIds(List.of(20L)); q.setIsInclude(true); },
q -> q.setPayTimeList(new LocalDateTime[]{LocalDateTime.of(2026, 10, 20, 0, 0), LocalDateTime.of(2026, 10, 20, 23, 59, 59)}),
q -> { q.setCreateTimeList(null); q.setPayTimeList(new LocalDateTime[]{LocalDateTime.of(2026, 10, 20, 0, 0), LocalDateTime.of(2026, 10, 21, 23, 59, 59)}); },
q -> q.setMchIds(List.of(100L, 200L)), q -> q.setMchTypeCodes(List.of("1")),
q -> q.setHardTypeCodes(List.of("1")), q -> q.setAfterSaleNo("old-refund"),
q -> q.setWalletTypeId(222L), q -> q.setDeptIds(List.of(99L)),
q -> { q.setMerchantDeptIds(List.of(10L)); q.setIsIncludeMerchantDept(true); }
);
for (int index = 0; index < cases.size(); index++) {
HardOrderDto dto = HardOrderStatisticsServiceTest.query();
cases.get(index).accept(dto);
assertMatchesList(dto, "filter case " + index);
}
}
@Test
void qrCodeThreeStatesIncludeNullAndNegativeAmountsCorrectly() {
var dto = HardOrderStatisticsServiceTest.query();
assertEquals(6L, service.overview(dto).getSummary().getOrderCount());
dto.setHasExtraPrice(true);
assertEquals(1L, service.overview(dto).getSummary().getOrderCount());
dto.setHasExtraPrice(false);
assertEquals(5L, service.overview(dto).getSummary().getOrderCount());
}
@Test
void permissionsAreAppliedBeforeAggregationAndPagination() {
allAccess = false;
for (int index = 0; index < 4; index++) {
departments = index == 0 || index == 2 ? List.of(10L) : List.of();
username = index == 1 || index == 2 ? "bob" : null;
var result = service.overview(HardOrderStatisticsServiceTest.query());
assertEquals(List.of(5L, 1L, 6L, 0L).get(index), result.getSummary().getOrderCount());
assertMatchesList(HardOrderStatisticsServiceTest.query(), "permission " + index);
var merchants = service.merchants(HardOrderStatisticsServiceTest.query(), 1, 100);
assertEquals(result.getSummary().getOrderCount().longValue(), merchants.records().stream().mapToLong(HardOrderStatisticsVo.Totals::getOrderCount).sum());
}
}
@Test
void refundJoinsDoNotMultiplyOrdersAndMissingMerchantsAreRetained() {
var overview = service.overview(HardOrderStatisticsServiceTest.query());
assertEquals(6L, overview.getSummary().getOrderCount());
assertEquals(1L, overview.getWithoutPayTime().getOrderCount());
assertEquals(new BigDecimal("100"), overview.getSummary().getRefundPrice());
var merchants = service.merchants(HardOrderStatisticsServiceTest.query(), 1, 100).records();
assertEquals(5, merchants.size());
assertEquals(2L, merchants.stream().filter(row -> Long.valueOf(100L).equals(row.getMerchantId())).findFirst().orElseThrow().getOrderCount());
assertTrue(merchants.stream().anyMatch(row -> row.getMerchantId() == null && row.getMerchantName().equals("未关联商户")));
assertTrue(merchants.stream().anyMatch(row -> Long.valueOf(300L).equals(row.getMerchantId()) && row.getMerchantName().contains("已删除")));
assertTrue(merchants.stream().anyMatch(row -> Long.valueOf(400L).equals(row.getMerchantId()) && row.getMerchantName().contains("不存在")));
}
@Test
void merchantPaginationHasNoMissingGroupsOrCountQuery() {
var first = service.merchants(HardOrderStatisticsServiceTest.query(), 1, 2);
var second = service.merchants(HardOrderStatisticsServiceTest.query(), 2, 2);
var last = service.merchants(HardOrderStatisticsServiceTest.query(), 3, 2);
assertTrue(first.hasMore());
assertTrue(second.hasMore());
assertFalse(last.hasMore());
var rows = new ArrayList<>(first.records());
rows.addAll(second.records());
rows.addAll(last.records());
assertEquals(5, rows.size());
assertEquals(5, rows.stream().map(HardOrderStatisticsVo.Merchant::getMerchantId).distinct().count());
assertEquals(200L, rows.get(0).getMerchantId());
assertNull(rows.get(4).getMerchantId());
}
@Test
void repeatedRequestsSeeNewDataAndStatementsHaveHardTimeouts() throws Exception {
assertEquals(6L, service.overview(HardOrderStatisticsServiceTest.query()).getSummary().getOrderCount());
insertOrder(99L, Map.of());
assertEquals(7L, service.overview(HardOrderStatisticsServiceTest.query()).getSummary().getOrderCount());
for (String name : List.of("overview", "merchants")) {
var statement = configuration.getMappedStatement(HardOrderStatisticsMapper.class.getName() + "." + name);
assertEquals(15, statement.getTimeout());
assertFalse(statement.isUseCache());
assertTrue(statement.isFlushCacheRequired());
}
}
/** 显式启用的百万行合成数据验证;用于查询形态与范围索引验收,不代替实际 OceanBase 压测。 */
@Test
@EnabledIfSystemProperty(named = "hard.order.statistics.performance", matches = "true")
void millionRowRangeAggregationBenchmark() throws Exception {
try (var statement = connection.createStatement()) {
statement.execute("""
insert into hard_order (id,tenant_id,del_flag,merchant_id,merchant_type,merchant_dept_id,
user_dept_id,user_id,hard_id,wallet_id,total_price,extra_price,coupon_price,pay_price,refund_price,
pay_type,order_type,order_status,create_by,create_time,pay_time)
select 1000+x, case when mod(x,10)=0 then 8 else 7 end, '0', 100+mod(x,1000), '1', 10+mod(x,5),
20+mod(x,5), 1, 501, 901, 1000, 100, 50, 1050, 100,
cast(mod(x,5) as varchar), '1', '20', 'alice',
dateadd('DAY',-mod(x,365),timestamp '2026-10-21 12:00:00'),
dateadd('DAY',-mod(x,365),timestamp '2026-10-21 12:00:00')
from system_range(1,1000000) generated(x);
create index idx_statistics_create on hard_order(tenant_id,del_flag,create_time,user_id);
create index idx_statistics_pay on hard_order(tenant_id,del_flag,pay_time);
""");
}
for (String scenario : List.of("create", "pay", "merchant", "department", "wallet")) {
var dto = HardOrderStatisticsServiceTest.query();
if (scenario.equals("pay")) { dto.setPayTimeList(dto.getCreateTimeList()); dto.setCreateTimeList(null); }
if (scenario.equals("merchant")) dto.setMerchantId(101L);
if (scenario.equals("department")) { dto.setMerchantDeptIds(List.of(10L)); dto.setIsIncludeMerchantDept(true); }
if (scenario.equals("wallet")) dto.setWalletTypeId(111L);
var params = Map.<String,Object>of("dto", dto, "scope", new HardOrderStatisticsScope(7L, true, List.of(), null));
var mapped = configuration.getMappedStatement(HardOrderStatisticsMapper.class.getName()+".overview");
var bound = mapped.getBoundSql(params);
try (var explain = connection.prepareStatement("explain analyze " + bound.getSql())) {
new org.apache.ibatis.scripting.defaults.DefaultParameterHandler(mapped, params, bound).setParameters(explain);
try (var plan = explain.executeQuery()) { assertTrue(plan.next()); assertTrue(plan.getString(1).contains("IDX_STATISTICS") || plan.getString(1).contains("idx_statistics")); }
}
long started = System.nanoTime();
var result = service.overview(dto);
long milliseconds = (System.nanoTime()-started)/1_000_000;
System.out.printf("SYNTHETIC_BENCHMARK %s totalRows=1000000 matched=%d days=%d millis=%d%n", scenario,
result.getSummary().getOrderCount(),result.getDaily().size(),milliseconds);
assertTrue(milliseconds < 15000, scenario+" exceeded the statement deadline");
}
long started = System.nanoTime();
var page = service.merchants(HardOrderStatisticsServiceTest.query(), 1, 20);
System.out.printf("SYNTHETIC_BENCHMARK merchant_page rows=%d hasMore=%s millis=%d%n", page.records().size(),page.hasMore(),(System.nanoTime()-started)/1_000_000);
assertEquals(20, page.records().size());
assertTrue(page.hasMore());
}
private void assertMatchesList(HardOrderDto dto, String label) {
var scope = DataScope.of();
scope.setScopeDeptName("merchant_dept_id");
scope.setDeptList(new ArrayList<>(departments));
scope.setUsername(username);
List<HardOrderVo> records = listMapper.scopeList(dto, scope);
var actual = service.overview(dto).getSummary();
assertEquals(records.size(), actual.getOrderCount(), label);
assertEquals(sum(records, HardOrderVo::getTotalPrice), actual.getTotalPrice(), label);
assertEquals(sum(records, HardOrderVo::getExtraPrice), actual.getExtraPrice(), label);
assertEquals(sum(records, HardOrderVo::getCouponPrice), actual.getCouponPrice(), label);
assertEquals(sum(records, HardOrderVo::getPayPrice), actual.getPayPrice(), label);
assertEquals(sum(records, HardOrderVo::getRefundPrice), actual.getRefundPrice(), label);
}
private BigDecimal sum(List<HardOrderVo> rows, java.util.function.Function<HardOrderVo, Integer> getter) {
return rows.stream().map(getter).filter(java.util.Objects::nonNull).map(BigDecimal::valueOf).reduce(BigDecimal.ZERO, BigDecimal::add);
}
private void createTables() throws Exception {
String ddl = """
create schema pjcloudx;
create schema pjcloudx_wallet;
create table hard_order (
id bigint primary key, tenant_id bigint, del_flag varchar(1), order_no varchar(50),
user_id bigint, user_dept_id bigint, merchant_id bigint, merchant_type varchar(10), merchant_dept_id bigint,
hard_id bigint, dept_id bigint, wallet_id bigint, parent_car_id bigint, order_source_type_id bigint,
order_type varchar(10), order_status varchar(10), pay_type varchar(10), hour_type varchar(10), sys_type varchar(10),
total_price int, extra_price int, coupon_record_id bigint, coupon_price int, pay_price int,
pay_time timestamp, pay_refund_id bigint, refund_price int, refund_time timestamp, refund_status varchar(10),
apply_cancel_time timestamp, android_order_code varchar(128), is_offline_order varchar(1), product_desc varchar(100),
offline_pay_success varchar(1), create_by varchar(50), create_time timestamp, update_by varchar(50), update_time timestamp);
create table hard_manager (id bigint primary key, tenant_id bigint, del_flag varchar(1), name varchar(50), type varchar(10), dept_id bigint);
create table pjcloudx.sys_user (user_id bigint primary key, tenant_id bigint, del_flag varchar(1), name varchar(50), phone varchar(20), avatar varchar(100));
create table pjcloudx.sys_dept (dept_id bigint primary key, tenant_id bigint, del_flag varchar(1), name varchar(50));
create table pjcloudx_wallet.mch_manage (id bigint primary key, tenant_id bigint, del_flag varchar(1), name varchar(50), types varchar(10), logo_pic varchar(100));
create table pjcloudx_wallet.wallet_manage (id bigint primary key, tenant_id bigint, del_flag varchar(1), wallet_type_id bigint);
create table pjcloudx_wallet.wallet_type (id bigint primary key, tenant_id bigint, del_flag varchar(1), unique_code varchar(50));
create table hard_after_sale (id bigint primary key, tenant_id bigint, del_flag varchar(1), order_id bigint, no varchar(50));
""";
try (var statement = connection.createStatement()) { statement.execute(ddl); }
}
private void seed() throws Exception {
try (var statement = connection.createStatement()) {
statement.execute("""
insert into hard_manager values (501,7,'0','设备1','1',10),(502,7,'0','设备2','2',20),(503,7,'1','已删除设备','1',10);
insert into pjcloudx_wallet.mch_manage values (100,7,'0','同名商户','1',null),(200,7,'0','同名商户','2',null),(300,7,'1','已删除商户','1',null);
insert into pjcloudx_wallet.wallet_manage values (901,7,'0',111),(902,7,'0',222),(903,7,'1',111);
insert into hard_after_sale values (11,7,'0',1,'old-refund'),(12,7,'0',1,'new-refund'),(13,8,'0',1,'other-tenant');
""");
}
insertOrder(1, Map.of("extra_price", 100, "coupon_price", 50, "pay_price", 1050, "refund_price", 100));
insertOrder(2, Map.of("merchant_id", 200L, "merchant_type", "2", "merchant_dept_id", 20L, "user_dept_id", 30L,
"hard_id", 502L, "wallet_id", 902L, "create_by", "bob", "hour_type", "2", "pay_type", "2", "order_type", "2"));
try (var statement = connection.createStatement()) { statement.execute("update hard_order set pay_price=2000,total_price=2000,pay_time='2026-10-21 12:00:00' where id=2"); }
insertOrder(3, nullable("merchant_id", null, "pay_time", null, "pay_price", 0, "refund_price", null));
insertOrder(4, Map.of("merchant_id", 300L, "extra_price", -10, "hard_id", 503L, "wallet_id", 903L, "pay_price", 400));
insertOrder(5, Map.of("tenant_id", 8L));
insertOrder(6, Map.of("del_flag", "1"));
insertOrder(7, nullable("total_price", null, "extra_price", null, "pay_price", null, "coupon_price", null, "refund_price", null));
insertOrder(8, Map.of("merchant_id", 400L, "pay_price", 800));
}
private Map<String, Object> nullable(Object... pairs) {
Map<String, Object> result = new HashMap<>();
for (int index = 0; index < pairs.length; index += 2) result.put((String) pairs[index], pairs[index + 1]);
return result;
}
private void insertOrder(long id, Map<String, Object> overrides) throws Exception {
Map<String, Object> values = new LinkedHashMap<>(Map.of("id", id, "tenant_id", 7L, "del_flag", "0", "merchant_id", 100L,
"merchant_type", "1", "merchant_dept_id", 10L, "user_dept_id", 20L, "user_id", 1L, "hard_id", 501L, "wallet_id", 901L));
values.putAll(Map.of("dept_id", 99L, "total_price", 1000, "extra_price", 0, "coupon_price", 0, "pay_price", 1000,
"refund_price", 0, "create_by", "alice", "hour_type", "1", "order_type", "1", "order_status", "20"));
values.put("pay_type", "1");
values.put("create_time", LocalDateTime.of(2026, 10, 19, 8, 0));
values.put("pay_time", LocalDateTime.of(2026, 10, 20, 12, 0));
values.putAll(overrides);
String sql = "insert into hard_order (" + String.join(",", values.keySet()) + ") values (" + String.join(",", java.util.Collections.nCopies(values.size(), "?")) + ")";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
int index = 1;
for (Object value : values.values()) statement.setObject(index++, value);
statement.executeUpdate();
}
}
}
@@ -15,6 +15,7 @@ import java.util.Arrays;
public enum ThirdKeyTypeEnum {
LB("10", "宁波银行"),
NFNCP("20", "农发农产品"),
;
private final String type;
@@ -14,6 +14,7 @@ import java.util.Arrays;
public enum ThirdPaySoureBizTypeEnum {
LB("10", "宁波银行"),
NFNCP("20", "农发农产品"),
;
private final String type;
@@ -10,4 +10,9 @@ public class ThirdPayLogDto extends ThirdPayLogEntity {
private LocalDate[] createTimeList;
/** 按 JSON 字段精确关联底单,避免请求全文模糊匹配到其他订单。 */
private String relatedPlatformOrderId;
private String relatedThirdOrderId;
}
@@ -1,17 +1,12 @@
package com.bsd.pjcloud.thirdparty.controller.pay;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayLogDto;
import com.bsd.pjcloud.thirdparty.entity.api.ThirdPayLogEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdPayLogService;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeApplyEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import com.bsd.pjcloud.common.excel.annotation.ResponseExcel;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@@ -23,7 +18,6 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Objects;
/**
* 第三方支付日志
@@ -40,19 +34,8 @@ public class ThirdPayLogController {
@Operation(summary = "分页查询", description = "分页查询")
@GetMapping("/page")
@PreAuthorize("@pms.hasPermission('third_thirdPayLog_view')")
public R getThirdPayLogPage(@ParameterObject Page page, @ParameterObject ThirdPayLogDto dto) {
LambdaQueryWrapper<ThirdPayLogEntity> wrapper = new LambdaQueryWrapper<ThirdPayLogEntity>()
.eq(ObjectUtil.isNotEmpty(dto.getPaySourceType()), ThirdPayLogEntity::getPaySourceType, dto.getPaySourceType())
.eq(ObjectUtil.isNotEmpty(dto.getBizType()), ThirdPayLogEntity::getBizType, dto.getBizType())
.between(
ObjectUtil.isNotEmpty(dto.getCreateTimeList()),
ThirdPayLogEntity::getCreateTime,
ObjectUtil.isNotEmpty(dto.getCreateTimeList()) ? dto.getCreateTimeList()[0] : null,
ObjectUtil.isNotEmpty(dto.getCreateTimeList()) ? dto.getCreateTimeList()[1] : null
)
.orderByDesc(ThirdPayLogEntity::getCreateTime)
.orderByDesc(ThirdPayLogEntity::getId);
return R.ok(thirdPayLogService.page(page, wrapper));
public R getThirdPayLogPage(@ParameterObject Page<ThirdPayLogEntity> page, @ParameterObject ThirdPayLogDto dto) {
return R.ok(thirdPayLogService.pageList(page, dto));
}
@Operation(summary = "通过id查询", description = "通过id查询")
@@ -89,7 +72,7 @@ public class ThirdPayLogController {
@ResponseExcel
@GetMapping("/export")
@PreAuthorize("@pms.hasPermission('third_thirdPayLog_export')")
public List<ThirdPayLogEntity> export(ThirdPayLogEntity thirdPayLog, Long[] ids) {
return thirdPayLogService.list(Wrappers.lambdaQuery(thirdPayLog).in(ArrayUtil.isNotEmpty(ids), ThirdPayLogEntity::getId, ids));
public List<ThirdPayLogEntity> export(ThirdPayLogDto dto, Long[] ids) {
return thirdPayLogService.exportList(dto, ids);
}
}
}
@@ -1,8 +1,16 @@
package com.bsd.pjcloud.thirdparty.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayLogDto;
import com.bsd.pjcloud.thirdparty.entity.api.ThirdPayLogEntity;
import java.util.List;
public interface ThirdPayLogService extends IService<ThirdPayLogEntity> {
}
Page<ThirdPayLogEntity> pageList(Page<ThirdPayLogEntity> page, ThirdPayLogDto dto);
List<ThirdPayLogEntity> exportList(ThirdPayLogDto dto, Long[] ids);
}
@@ -56,7 +56,7 @@ public class ThirdPayApiServiceImpl implements ThirdPayApiService {
@Override
public PayBaseVo<PayVo> walletPay(String json, Map<String, String> headers) {
long startTime = System.currentTimeMillis();
ThirdPayLogEntity logObj = ThirdPayLogEntity.builder().apiUrl("/third/third-pay-api/wallet-pay").bizType(ThirdPayApiBizTypeEnum.PAY.getType()).paySourceType(ThirdPaySoureBizTypeEnum.LB.getType()).header(JSONUtil.toJsonStr(headers)).req(json).build();
ThirdPayLogEntity logObj = ThirdPayLogEntity.builder().apiUrl("/third/third-pay-api/wallet-pay").bizType(ThirdPayApiBizTypeEnum.PAY.getType()).paySourceType(getPaySourceType(headers)).header(JSONUtil.toJsonStr(headers)).req(json).build();
// 开始处理
PayBaseVo baseVo = new PayBaseVo<PayVo>();
ThirdKeyManageEntity keySetting = keyManageService.checkSignAndReturnConfig(json, headers);
@@ -108,7 +108,7 @@ public class ThirdPayApiServiceImpl implements ThirdPayApiService {
@Override
public PayBaseVo<RefundVo> walletRefund(String json, Map<String, String> headers) {
long startTime = System.currentTimeMillis();
ThirdPayLogEntity logObj = ThirdPayLogEntity.builder().apiUrl("/third/third-pay-api/wallet-refund").bizType(ThirdPayApiBizTypeEnum.REFUND.getType()).paySourceType(ThirdPaySoureBizTypeEnum.LB.getType()).req(json).header(JSONUtil.toJsonStr(headers)).build();
ThirdPayLogEntity logObj = ThirdPayLogEntity.builder().apiUrl("/third/third-pay-api/wallet-refund").bizType(ThirdPayApiBizTypeEnum.REFUND.getType()).paySourceType(getPaySourceType(headers)).req(json).header(JSONUtil.toJsonStr(headers)).build();
// 开始处理
PayBaseVo baseVo = new PayBaseVo<RefundVo>();
ThirdKeyManageEntity keySetting = keyManageService.checkSignAndReturnConfig(json, headers);
@@ -161,7 +161,7 @@ public class ThirdPayApiServiceImpl implements ThirdPayApiService {
public PayBaseVo<PayWalletInfoVo> getWalletInfo(String json, Map<String, String> headers) {
// 日志
long startTime = System.currentTimeMillis();
ThirdPayLogEntity logObj = ThirdPayLogEntity.builder().apiUrl("/third/third-pay-api/user-wallet").bizType(ThirdPayApiBizTypeEnum.WALLET_INFO.getType()).req(json).header(JSONUtil.toJsonStr(headers)).paySourceType(ThirdPaySoureBizTypeEnum.LB.getType()).build();
ThirdPayLogEntity logObj = ThirdPayLogEntity.builder().apiUrl("/third/third-pay-api/user-wallet").bizType(ThirdPayApiBizTypeEnum.WALLET_INFO.getType()).req(json).header(JSONUtil.toJsonStr(headers)).paySourceType(getPaySourceType(headers)).build();
// 开始处理
PayBaseVo baseVo = new PayBaseVo<PayWalletInfoVo>();
ThirdKeyManageEntity keySetting = keyManageService.checkSignAndReturnConfig(json, headers);
@@ -240,7 +240,7 @@ public class ThirdPayApiServiceImpl implements ThirdPayApiService {
public PayBaseVo<List<PayWaterVo>> water(String json, Map<String, String> headers) {
// 日志
long startTime = System.currentTimeMillis();
ThirdPayLogEntity logObj = ThirdPayLogEntity.builder().apiUrl("/third/third-pay-api/water").bizType(ThirdPayApiBizTypeEnum.WATER.getType()).req(json).header(JSONUtil.toJsonStr(headers)).paySourceType(ThirdPaySoureBizTypeEnum.LB.getType()).build();
ThirdPayLogEntity logObj = ThirdPayLogEntity.builder().apiUrl("/third/third-pay-api/water").bizType(ThirdPayApiBizTypeEnum.WATER.getType()).req(json).header(JSONUtil.toJsonStr(headers)).paySourceType(getPaySourceType(headers)).build();
// 开始处理
PayBaseVo baseVo = new PayBaseVo<PayWalletInfoVo>();
ThirdKeyManageEntity keySetting = keyManageService.checkSignAndReturnConfig(json, headers);
@@ -272,6 +272,10 @@ public class ThirdPayApiServiceImpl implements ThirdPayApiService {
for (PayWaterVo item : pageList.getData()) {
item.setSignPhone(LbRetailSignUtils.sm4Encrypt(item.getSignPhone(), keySetting.getFieldKey()));
}
// 日志
logObj.setRes(JSONUtil.toJsonStr(pageList));
logObj.setTimeConsuming(Long.toString(System.currentTimeMillis() - startTime));
payLogService.save(logObj);
return pageList;
} catch (Exception e) {
log.error("|{}|流水查询,json|{}|headers|{}|", TAG, json, headers, e);
@@ -291,7 +295,7 @@ public class ThirdPayApiServiceImpl implements ThirdPayApiService {
public PayBaseVo<PayWaterVo> detail(String json, Map<String, String> headers) {
// 日志
long startTime = System.currentTimeMillis();
ThirdPayLogEntity logObj = ThirdPayLogEntity.builder().apiUrl("/third/third-pay-api/detail").bizType(ThirdPayApiBizTypeEnum.DETAIL.getType()).req(json).header(JSONUtil.toJsonStr(headers)).paySourceType(ThirdPaySoureBizTypeEnum.LB.getType()).build();
ThirdPayLogEntity logObj = ThirdPayLogEntity.builder().apiUrl("/third/third-pay-api/detail").bizType(ThirdPayApiBizTypeEnum.DETAIL.getType()).req(json).header(JSONUtil.toJsonStr(headers)).paySourceType(getPaySourceType(headers)).build();
// 开始处理
PayBaseVo baseVo = new PayBaseVo<PayWaterVo>();
ThirdKeyManageEntity keySetting = keyManageService.checkSignAndReturnConfig(json, headers);
@@ -330,6 +334,10 @@ public class ThirdPayApiServiceImpl implements ThirdPayApiService {
data.setSignPhone(LbRetailSignUtils.sm4Encrypt(data.getSignPhone(), keySetting.getFieldKey()));
}
}
// 日志
logObj.setRes(JSONUtil.toJsonStr(orderDetail));
logObj.setTimeConsuming(Long.toString(System.currentTimeMillis() - startTime));
payLogService.save(logObj);
return orderDetail;
} catch (Exception e) {
log.error("|{}|订单详情,json|{}|headers|{}|", TAG, json, headers, e);
@@ -341,4 +349,20 @@ public class ThirdPayApiServiceImpl implements ThirdPayApiService {
return baseVo;
}
}
/**
* 解析请求声明的支付来源,供验签前记录日志。
*/
private String getPaySourceType(Map<String, String> headers) {
if (ObjectUtil.isEmpty(headers)) {
return "";
}
String paySourceType = "";
for (Map.Entry<String, String> entry : headers.entrySet()) {
if ("BIZ-TYPE".equalsIgnoreCase(entry.getKey())) {
paySourceType = entry.getValue();
}
}
return paySourceType == null ? "" : paySourceType;
}
}
@@ -1,6 +1,11 @@
package com.bsd.pjcloud.thirdparty.service.impl.pay;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayLogDto;
import com.bsd.pjcloud.thirdparty.entity.api.ThirdPayLogEntity;
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayLogMapper;
import com.bsd.pjcloud.thirdparty.service.ThirdPayLogService;
@@ -9,6 +14,13 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 第三方支付日志
*/
@@ -18,4 +30,66 @@ import org.springframework.validation.annotation.Validated;
@RequiredArgsConstructor
public class ThirdPayLogServiceImpl extends ServiceImpl<ThirdPayLogMapper, ThirdPayLogEntity> implements ThirdPayLogService {
}
@Override
public Page<ThirdPayLogEntity> pageList(Page<ThirdPayLogEntity> page, ThirdPayLogDto dto) {
// 先分页查询主键,避免携带 req/res/header 等大文本执行分页排序。
baseMapper.selectPage(page, buildQuery(dto).select(ThirdPayLogEntity::getId));
if (page.getRecords().isEmpty()) {
return page;
}
List<Long> ids = page.getRecords().stream().map(ThirdPayLogEntity::getId).toList();
Map<Long, ThirdPayLogEntity> records = baseMapper.selectByIds(ids).stream()
.collect(Collectors.toMap(ThirdPayLogEntity::getId, Function.identity()));
// IN 查询不保证顺序,按主键分页结果还原 create_time DESC, id DESC。
page.setRecords(ids.stream().map(records::get).filter(Objects::nonNull).toList());
return page;
}
@Override
public List<ThirdPayLogEntity> exportList(ThirdPayLogDto dto, Long[] ids) {
return baseMapper.selectList(buildQuery(dto)
.in(ArrayUtil.isNotEmpty(ids), ThirdPayLogEntity::getId, (Object[]) ids));
}
private LambdaQueryWrapper<ThirdPayLogEntity> buildQuery(ThirdPayLogDto dto) {
LambdaQueryWrapper<ThirdPayLogEntity> wrapper = new LambdaQueryWrapper<ThirdPayLogEntity>()
.eq(dto.getId() != null, ThirdPayLogEntity::getId, dto.getId())
.eq(StrUtil.isNotBlank(dto.getPaySourceType()), ThirdPayLogEntity::getPaySourceType, StrUtil.trim(dto.getPaySourceType()))
.eq(StrUtil.isNotBlank(dto.getBizType()), ThirdPayLogEntity::getBizType, StrUtil.trim(dto.getBizType()))
.eq(StrUtil.isNotBlank(dto.getBizId()), ThirdPayLogEntity::getBizId, StrUtil.trim(dto.getBizId()))
.like(StrUtil.isNotBlank(dto.getApiUrl()), ThirdPayLogEntity::getApiUrl, StrUtil.trim(dto.getApiUrl()))
.like(StrUtil.isNotBlank(dto.getReq()), ThirdPayLogEntity::getReq, StrUtil.trim(dto.getReq()))
.like(StrUtil.isNotBlank(dto.getRes()), ThirdPayLogEntity::getRes, StrUtil.trim(dto.getRes()))
.like(StrUtil.isNotBlank(dto.getHeader()), ThirdPayLogEntity::getHeader, StrUtil.trim(dto.getHeader()))
.like(StrUtil.isNotBlank(dto.getError()), ThirdPayLogEntity::getError, StrUtil.trim(dto.getError()));
if (StrUtil.isNotBlank(dto.getRelatedPlatformOrderId()) || StrUtil.isNotBlank(dto.getRelatedThirdOrderId())) {
wrapper.and(related -> {
if (StrUtil.isNotBlank(dto.getRelatedPlatformOrderId())) {
related.apply(jsonField("req", "$.platformOrderId") + " = {0}", dto.getRelatedPlatformOrderId())
.or().apply(jsonField("res", "$.data.platformOrderId") + " = {0}", dto.getRelatedPlatformOrderId());
}
if (StrUtil.isNotBlank(dto.getRelatedThirdOrderId())) {
if (StrUtil.isNotBlank(dto.getRelatedPlatformOrderId())) related.or();
related.apply(jsonField("req", "$.thirdOrderId") + " = {0}", dto.getRelatedThirdOrderId())
.or().apply(jsonField("res", "$.data.thirdOrderId") + " = {0}", dto.getRelatedThirdOrderId());
}
});
}
LocalDate[] dates = dto.getCreateTimeList();
if (dates != null) {
if (dates.length > 0 && dates[0] != null) {
wrapper.ge(ThirdPayLogEntity::getCreateTime, dates[0].atStartOfDay());
}
if (dates.length > 1 && dates[1] != null) {
wrapper.lt(ThirdPayLogEntity::getCreateTime, dates[1].plusDays(1).atStartOfDay());
}
}
return wrapper.orderByDesc(ThirdPayLogEntity::getCreateTime).orderByDesc(ThirdPayLogEntity::getId);
}
/** 历史日志可能不是合法 JSON,先兜底,防止一条坏日志导致整页查询失败。 */
private String jsonField(String column, String path) {
return "JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(" + column + "), " + column + ", '{}'), '" + path + "'))";
}
}
@@ -119,14 +119,16 @@ public class PayTest {
log.info(res);
}
// 退款
@Test
public void walletRefund() throws Exception {
ThirdKeyManageEntity config = getLbConfig();
Map<String, Object> paramsMap = new HashMap<>();
paramsMap.put("platformOrderId", "1935235239046193154");
paramsMap.put("thirdRefundId", System.currentTimeMillis());
paramsMap.put("price", "100");
paramsMap.put("platformOrderId", "2081322799783981057");
paramsMap.put("thirdRefundId", "2026073020164100007");
paramsMap.put("price", "14100");
paramsMap.put("userId", "1895298968098435074");
String body = JSONUtil.toJsonStr(paramsMap);
// 发送请求
String res = HttpRequest.post(baseUrl + walletRefundUrl).contentType("application/json").header(apiCountersignProd(body)).body(body).execute().body();
@@ -0,0 +1,44 @@
package com.bsd.pjcloud.thirdparty.service.impl.pay;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayLogDto;
import com.bsd.pjcloud.thirdparty.entity.api.ThirdPayLogEntity;
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayLogMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class ThirdPayLogOrderAssociationTest {
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void associationsUseBoundExactJsonFieldsAndRemainGroupedWithExistingFilters() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), ThirdPayLogEntity.class);
ThirdPayLogMapper mapper = mock(ThirdPayLogMapper.class);
ThirdPayLogServiceImpl service = new ThirdPayLogServiceImpl();
ReflectionTestUtils.setField(service, "baseMapper", mapper);
ThirdPayLogDto dto = new ThirdPayLogDto();
dto.setBizType("20");
dto.setRelatedPlatformOrderId("900719925474099312");
dto.setRelatedThirdOrderId("third-order-'quoted");
service.exportList(dto, null);
ArgumentCaptor<LambdaQueryWrapper> capture = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(mapper).selectList(capture.capture());
LambdaQueryWrapper<?> wrapper = capture.getValue();
String sql = wrapper.getSqlSegment();
assertTrue(sql.contains("AND ("), "订单关联 OR 条件必须被其他筛选条件约束");
assertTrue(sql.contains("JSON_VALID(req)"));
assertTrue(sql.contains("JSON_VALID(res)"));
assertTrue(sql.contains("$.platformOrderId"));
assertTrue(sql.contains("$.data.thirdOrderId"));
assertFalse(sql.contains("900719925474099312"));
assertFalse(sql.contains("third-order-'quoted"));
assertTrue(wrapper.getParamNameValuePairs().containsValue("900719925474099312"));
assertTrue(wrapper.getParamNameValuePairs().containsValue("third-order-'quoted"));
}
}
@@ -0,0 +1,101 @@
package com.bsd.pjcloud.thirdparty.service.impl.pay;
import com.bsd.pjcloud.hard.api.feign.RemoteHardThirdPayService;
import com.bsd.pjcloud.thirdparty.constant.ThirdKeyTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaySoureBizTypeEnum;
import com.bsd.pjcloud.thirdparty.dto.pay.WaterDto;
import com.bsd.pjcloud.thirdparty.entity.ThirdKeyManageEntity;
import com.bsd.pjcloud.thirdparty.entity.api.ThirdPayLogEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdPayLogService;
import com.bsd.pjcloud.thirdparty.service.impl.ThirdKeyManageServiceImpl;
import com.bsd.pjcloud.thirdparty.vo.pay.PayBaseVo;
import com.bsd.pjcloud.thirdparty.vo.pay.PayWaterVo;
import org.apache.commons.codec.digest.DigestUtils;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.params.provider.Arguments;
import org.mockito.ArgumentCaptor;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ThirdPayWaterSourceTest {
private static final String JSON = "{\"current\":2,\"size\":500,\"paySourceType\":\"untrusted-body-value\"}";
static Stream<Arguments> sources() {
return Stream.of(ThirdPaySoureBizTypeEnum.values()).flatMap(source ->
Stream.of("BIZ-TYPE", "biz-type", "Biz-Type").map(name -> Arguments.of(source, name)));
}
@ParameterizedTest
@MethodSource("sources")
void signedHeaderControlsWaterSourceRegardlessOfRequestBody(ThirdPaySoureBizTypeEnum source, String headerName) {
Fixture fixture = new Fixture(source);
PayWaterVo item = new PayWaterVo();
item.setSignPhone("13800000000");
PayBaseVo<List<PayWaterVo>> response = new PayBaseVo<>();
response.setCode(200);
response.setData(List.of(item));
when(fixture.hardware.waterList(any(), eq(source))).thenReturn(response);
Map<String, String> headers = fixture.headers();
headers.put(headerName, source.getType());
assertEquals(200, fixture.service.water(JSON, headers).getCode());
ArgumentCaptor<WaterDto> request = ArgumentCaptor.forClass(WaterDto.class);
verify(fixture.hardware).waterList(request.capture(), eq(source));
assertEquals(2L, request.getValue().getCurrent());
assertEquals(200L, request.getValue().getSize());
ArgumentCaptor<ThirdPayLogEntity> log = ArgumentCaptor.forClass(ThirdPayLogEntity.class);
verify(fixture.logs).save(log.capture());
assertEquals(source.getType(), log.getValue().getPaySourceType());
}
@ParameterizedTest
@NullSource
@ValueSource(strings = {"", "99"})
void missingOrInvalidBizTypeCannotQueryWater(String source) {
Fixture fixture = new Fixture(ThirdPaySoureBizTypeEnum.LB);
Map<String, String> headers = fixture.headers();
if (source != null) headers.put("BIZ-TYPE", source);
assertEquals(400, fixture.service.water(JSON, headers).getCode());
verify(fixture.hardware, never()).waterList(any(), any());
verify(fixture.logs).save(any(ThirdPayLogEntity.class));
}
private static class Fixture {
final ThirdKeyManageEntity config = new ThirdKeyManageEntity();
final RemoteHardThirdPayService hardware = mock(RemoteHardThirdPayService.class);
final ThirdPayLogService logs = mock(ThirdPayLogService.class);
final ThirdPayApiServiceImpl service;
Fixture(ThirdPaySoureBizTypeEnum source) {
config.setBizType(source.getType());
config.setAppKey("isolated-key");
config.setAppSecret("isolated-secret");
config.setFieldKey("0123456789abcdef");
ThirdKeyManageServiceImpl keys = new ThirdKeyManageServiceImpl() {
@Override
public ThirdKeyManageEntity get(ThirdKeyTypeEnum type) {
return config.getBizType().equals(type.getType()) ? config : null;
}
};
service = new ThirdPayApiServiceImpl(keys, logs, null, null, null, hardware);
}
Map<String, String> headers() {
Map<String, String> headers = new LinkedHashMap<>();
headers.put("app-key", config.getAppKey());
headers.put("timestamp", "1");
headers.put("sign", DigestUtils.md5Hex(config.getAppKey() + config.getAppSecret()
+ config.getBizType() + "1" + JSON));
return headers;
}
}
}
+5
View File
@@ -33,6 +33,11 @@
<description>pjcloud 通用用户权限管理系统业务处理模块</description>
<dependencies>
<!-- 通用异步导出及租户隔离的任务进度。 -->
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-async-task</artifactId>
</dependency>
<dependency>
<groupId>com.qiniu</groupId>
<artifactId>qiniu-java-sdk</artifactId>
@@ -19,6 +19,7 @@
package com.bsd.pjcloud.admin.controller;
import jakarta.servlet.http.HttpServletResponse;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
@@ -92,6 +93,9 @@ import java.util.stream.Collectors;
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class SysUserController {
/** 复用原 UserExcelVO 的分批导出。 */
private final com.bsd.pjcloud.admin.service.impl.SysUserBatchExportService batchExportService;
private final SysUserService userService;
private final RemoterVisitorService remoterVisitorService;
@@ -353,11 +357,17 @@ public class SysUserController {
* @param userDTO 查询条件
* @return
*/
@ResponseExcel
@PostMapping("/export")
@HasPermission("sys_user_export")
public List export(@RequestBody UserDTO userDTO) {
return userService.listUser(userDTO, null);
public void export(HttpServletResponse response, @RequestBody UserDTO userDTO) throws Exception {
batchExportService.export(response, userDTO);
}
/** 提交用户导出任务,数据范围与旧导出接口保持一致。 */
@PostMapping("/export-async")
@HasPermission("sys_user_export")
public R<com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO> exportAsync(@RequestBody UserDTO userDTO) {
return R.ok(batchExportService.submit(userDTO), "用户导出任务已提交");
}
/**
@@ -76,6 +76,9 @@ public interface SysUserMapper extends PjcloudBaseMapper<SysUser> {
* @param dataScope 数据权限声明
* @return
*/
/** 仅供导出使用的分页,查询条件与原完整导出一致,主键用于稳定排序。 */
Page<UserVO> selectVoExportPageByScope(Page<UserVO> page, @Param("query") UserDTO userDTO, @Param("ids") Long[] ids, DataScope dataScope);
List<UserVO> selectVoListByScope(@Param("query") UserDTO userDTO, @Param("ids") Long[] ids, DataScope dataScope);
/**
@@ -0,0 +1,134 @@
package com.bsd.pjcloud.admin.service.impl;
import cn.hutool.core.bean.BeanUtil;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.builder.ExcelWriterBuilder;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.constant.UserStaffingTypeEnum;
import com.bsd.pjcloud.admin.api.entity.*;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
import com.bsd.pjcloud.admin.api.vo.*;
import com.bsd.pjcloud.admin.mapper.SysUserMapper;
import com.bsd.pjcloud.admin.service.SysFileService;
import com.bsd.pjcloud.common.asynctask.service.*;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.excel.converters.*;
import com.bsd.pjcloud.common.excel.config.ExcelConfigProperties;
import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate;
import com.bsd.pjcloud.common.excel.stream.ExportMultipartFile;
import com.bsd.pjcloud.common.excel.style.ExcelExportStyleHandler;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Collectors;
/** 沿用 UserExcelVO 及原转换器的用户分页导出,不引入参考项目的新列。 */
@Service
@RequiredArgsConstructor
public class SysUserBatchExportService {
/** 仅异步导出移除这三列,不修改共享的导入模型和同步导出。 */
private static final Set<String> ASYNC_EXCLUDED_FIELDS = Set.of("postNameList", "staffingTypeParse", "lockFlag");
/** 固定列宽,不扫描全部用户;可用全局 token 按字段覆盖。 */
private static final Map<String, Double> COLUMN_WIDTHS = Map.ofEntries(
Map.entry("userId", 24D), Map.entry("username", 20D), Map.entry("phone", 18D),
Map.entry("nickname", 18D), Map.entry("name", 14D), Map.entry("email", 32D),
Map.entry("deptName", 28D), Map.entry("roleNameList", 30D),
Map.entry("staffingType", 14D), Map.entry("createTime", 24D));
private final SysUserMapper mapper;
private final SysFileService files;
private final AsyncTaskExecutorService tasks;
private final ExcelConfigProperties excelConfig;
/** 固定原导出条件,立即返回任务 ID。 */
public AsyncTaskProgressVO submit(UserDTO dto) {
UserDTO query = BeanUtil.toBean(dto, UserDTO.class);
ExcelExportStyleHandler style = new ExcelExportStyleHandler(excelConfig.getExportStyle(), COLUMN_WIDTHS);
return tasks.submitWithResult("admin:export:user", "用户导出", 1,
reporter -> generate(query, reporter, style, ASYNC_EXCLUDED_FIELDS, this::upload));
}
/** 保持旧同步接口的文件响应。 */
public void export(HttpServletResponse response, UserDTO dto) throws Exception {
generate(dto, null, null, Set.of(), file -> BatchExportSupport.respond(file, "用户管理.xlsx", response));
}
/** 每次只保存一个用户批次,复用旧列声明、日期及长整数转换器。 */
private <R> R generate(UserDTO query, AsyncTaskProgressReporter reporter, ExcelExportStyleHandler style,
Set<String> excludedFields,
BatchExcelExportTemplate.FileConsumer<R> consumer) throws Exception {
Page<UserVO> first = fetch(query, 1);
long total = first.getTotal();
if (total == 0) throw new AsyncTaskException("当前筛选条件没有可导出的用户");
int maximum = BatchExcelExportTemplate.calculateMaxDetailRowCount(1, 0);
if (total > maximum) throw new AsyncTaskException("导出数据超过单工作表支持的 " + maximum + " 条,请缩小筛选范围");
if (reporter != null) reporter.updateTotal(BatchExcelExportTemplate.calculateProgressTotal(total, BatchExportSupport.BATCH_SIZE, 2), "共 " + total + " 个用户,正在生成 Excel");
Path output = Files.createTempFile("user-export-", ".xlsx");
try {
long written = 0;
ExcelWriterBuilder builder = EasyExcel.write(output.toFile()).head(UserExcelVO.class)
.excludeColumnFieldNames(excludedFields)
.registerConverter(LocalTimeStringConverter.INSTANCE)
.registerConverter(LocalDateStringConverter.INSTANCE)
.registerConverter(LocalDateTimeStringConverter.INSTANCE)
.registerConverter(LongStringConverter.INSTANCE)
.registerConverter(StringArrayConverter.INSTANCE);
if (style != null) builder.registerWriteHandler(style);
try (ExcelWriter writer = builder.build()) {
WriteSheet sheet = EasyExcel.writerSheet("sheet1").build();
for (long current = 1; ; current++) {
List<UserVO> rows = current == 1 ? first.getRecords() : fetch(query, current).getRecords();
if (rows.isEmpty()) break;
written += rows.size();
if (written > total) throw new AsyncTaskException("导出期间用户数据发生变化,请重试");
writer.write(rows.stream().map(this::convert).collect(Collectors.toList()), sheet);
if (reporter != null) reporter.advance("已写入 " + written + " / " + total + " 个用户");
}
if (written != total) throw new AsyncTaskException("导出期间用户数据发生变化,请重试");
}
if (reporter != null) reporter.advance("文件已生成,正在保存导出结果");
return consumer.consume(output);
} finally {
Files.deleteIfExists(output);
}
}
/** 新分页复用原导出 SQL,保持它与列表筛选能力的既有差异。 */
private Page<UserVO> fetch(UserDTO query, long current) {
return mapper.selectVoExportPageByScope(BatchExportSupport.page(current), query, null, DataScope.of());
}
/** 与原 listUser 的角色、岗位及编制转换一致。 */
private UserExcelVO convert(UserVO user) {
UserExcelVO row = new UserExcelVO();
BeanUtils.copyProperties(user, row);
row.setRoleNameList(user.getRoleList().stream().map(SysRole::getRoleName).collect(Collectors.joining(",")));
row.setPostNameList(user.getPostList().stream().map(SysPost::getPostName).collect(Collectors.joining(",")));
if (user.getStaffingType() != null) {
UserStaffingTypeEnum type = UserStaffingTypeEnum.valueOfByType(user.getStaffingType());
if (type != null) row.setStaffingType(type.getDescription());
}
return row;
}
/** 本地文件流交给现有文件中心,避免内存持有完整用户列表。 */
private AsyncTaskResult upload(Path file) {
String name = "用户管理.xlsx";
String encoded = new String(name.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
R<?> result = files.uploadFile(new ExportMultipartFile(file, encoded), "export", null, "excel");
if (result == null || !result.isOk() || !(result.getData() instanceof Map<?, ?> data) || data.get("url") == null) {
throw new AsyncTaskException("导出文件保存失败,请重试");
}
return AsyncTaskResult.file(data.get("url").toString(), name);
}
}
@@ -319,6 +319,83 @@
</if>
</select>
<!-- 导出专用分页,保留原导出 SQL 的筛选与关联。 -->
<select id="selectVoExportPageByScope" resultMap="baseResultMap">
SELECT
u.user_id,
u.username,
u.password,
u.salt,
u.phone,
u.avatar,
u.wx_openid,
u.qq_openid,
u.gitee_login,
u.osc_id,
u.wx_cp_userid,
u.wx_ding_userid,
u.dept_id,
u.create_by,
u.create_time ucreate_time,
u.update_time uupdate_time,
u.del_flag,
u.lock_flag,
u.password_expire_flag,
u.password_modify_time,
u.tenant_id,
u.nickname,
u.name,
u.email,
u.staffing_type,
d.name dept_name
FROM
sys_user u
LEFT JOIN sys_dept d ON d.dept_id = u.dept_id
LEFT JOIN sys_user_role urole ON urole.user_id = u.user_id
<where>
u.del_flag = '0'
<if test="query.username != null and query.username != ''">
<bind name="usernameLike" value="'%'+query.username+'%'"/>
AND u.username LIKE #{usernameLike}
</if>
<if test="query.deptId != null and query.deptId != ''">
AND u.dept_id = #{query.deptId}
</if>
<if test="query.role != null and query.role.size() != 0">
and urole.role_id IN
<foreach collection="query.role" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="query.deptIds != null and query.deptIds.size() != 0">
<choose>
<when test="query.isInclude != null and query.isInclude == true">
and u.dept_id IN
</when>
<when test="query.isInclude != null and query.isInclude == false">
and u.dept_id NOT IN
</when>
<otherwise>
and u.dept_id IN
</otherwise>
</choose>
<foreach collection="query.deptIds" index="index" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</if>
<if test="ids != null">
AND u.user_id in
<foreach collection="ids" index="index" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</if>
</where>
group by u.user_id
<if test="_databaseId != 'mssql'">
ORDER BY u.create_time DESC, u.user_id DESC
</if>
</select>
<select id="staffingListByDept" resultType="com.bsd.pjcloud.admin.api.vo.total.UserStaffingTotalVo">
select a.dept_id,
count(0) as deptTotalPerson,
@@ -0,0 +1,59 @@
package com.bsd.pjcloud.admin.service.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.constant.UserStaffingTypeEnum;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
import com.bsd.pjcloud.admin.api.entity.*;
import com.bsd.pjcloud.admin.api.vo.UserVO;
import com.bsd.pjcloud.admin.mapper.SysUserMapper;
import com.bsd.pjcloud.common.excel.config.ExcelConfigProperties;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletResponse;
import java.io.ByteArrayInputStream;
import java.time.LocalDateTime;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** 用户导出使用原有列,分页后仍保持角色、岗位、编制和长编号。 */
class SysUserBatchExportTest {
@Test void pagedUsersKeepOriginalColumnsAndFullPrecisionIds() throws Exception {
SysUserMapper mapper = mock(SysUserMapper.class);
SysRole first = new SysRole(); first.setRoleName("角色甲");
SysRole second = new SysRole(); second.setRoleName("角色乙");
SysPost post = new SysPost(); post.setPostName("岗位");
UserStaffingTypeEnum type = UserStaffingTypeEnum.values()[0];
when(mapper.selectVoExportPageByScope(any(), any(), isNull(), any())).thenAnswer(call -> {
Page<UserVO> request = call.getArgument(0);
List<UserVO> users = new ArrayList<>();
long from = (request.getCurrent() - 1) * request.getSize();
for (long index = from; index < Math.min(from + request.getSize(), 201); index++) {
UserVO user = new UserVO();
user.setUserId(90071992547409930L + index); user.setUsername("用户" + index);
user.setRoleList(List.of(first, second)); user.setPostList(List.of(post));
user.setStaffingType(type.getType()); user.setCreateTime(LocalDateTime.of(2026, 10, 21, 10, 20, 30));
users.add(user);
}
return new Page<UserVO>(request.getCurrent(), request.getSize()).setRecords(users).setTotal(201);
});
MockHttpServletResponse response = new MockHttpServletResponse();
new SysUserBatchExportService(mapper, null, null, new ExcelConfigProperties()).export(response, new UserDTO());
try (Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(response.getContentAsByteArray()))) {
Sheet sheet = workbook.getSheetAt(0);
Map<String, Integer> columns = new HashMap<>();
for (Cell cell : sheet.getRow(0)) columns.put(cell.getStringCellValue(), cell.getColumnIndex());
assertEquals(13, columns.size());
assertEquals(201, sheet.getLastRowNum());
assertEquals("90071992547409930", sheet.getRow(1).getCell(columns.get("用户编号")).getStringCellValue());
assertEquals("90071992547410130", sheet.getRow(201).getCell(columns.get("用户编号")).getStringCellValue());
assertEquals("角色甲,角色乙", sheet.getRow(1).getCell(columns.get("角色")).getStringCellValue());
assertEquals("岗位", sheet.getRow(1).getCell(columns.get("岗位名称")).getStringCellValue());
assertEquals(type.getDescription(), sheet.getRow(1).getCell(columns.get("编制")).getStringCellValue());
assertEquals("2026-10-21 10:20:30", sheet.getRow(1).getCell(columns.get("创建时间")).getStringCellValue());
}
verify(mapper, times(3)).selectVoExportPageByScope(any(), any(), isNull(), any());
}
}
@@ -0,0 +1,330 @@
package com.bsd.pjcloud.admin.service.impl;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.format.NumberFormat;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.constant.UserStaffingTypeEnum;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
import com.bsd.pjcloud.admin.api.entity.SysPost;
import com.bsd.pjcloud.admin.api.entity.SysRole;
import com.bsd.pjcloud.admin.api.vo.UserVO;
import com.bsd.pjcloud.admin.mapper.SysUserMapper;
import com.bsd.pjcloud.admin.service.SysFileService;
import com.bsd.pjcloud.common.asynctask.service.*;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.excel.config.ExcelConfigProperties;
import com.bsd.pjcloud.common.excel.style.ExcelExportStyleHandler;
import com.bsd.pjcloud.common.excel.style.ExcelStyleTokens;
import lombok.AllArgsConstructor;
import lombok.Data;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.*;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.*;
import java.util.zip.ZipFile;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class SysUserExportStyleTest {
@Test
void asyncExcludesRequestedColumnsAndPreservesRemainingContentAndStyles() throws Exception {
Harness harness = new Harness(new ExcelConfigProperties());
byte[] original = harness.synchronous();
harness.submit();
byte[] exported = harness.run();
try (XSSFWorkbook before = workbook(original); XSSFWorkbook after = workbook(exported)) {
Sheet sheet = after.getSheetAt(0);
assertContentEquals(before.getSheetAt(0), sheet);
List<String> headers = new ArrayList<>();
sheet.getRow(0).forEach(cell -> headers.add(cell.getStringCellValue()));
assertEquals(List.of("用户编号", "用户名", "手机号", "昵称", "姓名", "邮箱", "部门名称", "角色", "编制", "创建时间"), headers);
assertEquals(10, sheet.getRow(0).getLastCellNum());
assertEquals(401, sheet.getLastRowNum());
Cell id = sheet.getRow(1).getCell(0);
assertEquals(CellType.STRING, id.getCellType());
assertEquals("90071992547409930", id.getStringCellValue());
assertEquals("90071992547410330", sheet.getRow(401).getCell(0).getStringCellValue());
XSSFCellStyle header = (XSSFCellStyle) sheet.getRow(0).getCell(0).getCellStyle();
assertEquals("Microsoft YaHei", header.getFont().getFontName());
assertEquals(11, header.getFont().getFontHeightInPoints());
assertTrue(header.getFont().getBold());
assertEquals("FFFFFF", rgb(header.getFont().getXSSFColor()));
assertEquals("1F4E78", rgb(header.getFillForegroundXSSFColor()));
assertEquals(HorizontalAlignment.CENTER, header.getAlignment());
assertEquals(36F, sheet.getRow(0).getHeightInPoints());
assertTrue(header.getWrapText());
XSSFCellStyle body = (XSSFCellStyle) id.getCellStyle();
assertEquals("Microsoft YaHei", body.getFont().getFontName());
assertFalse(body.getFont().getBold());
assertEquals("243247", rgb(body.getFont().getXSSFColor()));
assertEquals("FFFFFF", rgb(body.getFillForegroundXSSFColor()));
assertEquals(HorizontalAlignment.LEFT, body.getAlignment());
assertEquals(VerticalAlignment.CENTER, body.getVerticalAlignment());
assertEquals(BorderStyle.THIN, body.getBorderBottom());
assertEquals(BorderStyle.NONE, body.getBorderLeft());
assertEquals(BorderStyle.NONE, body.getBorderRight());
assertEquals("DCE3EC", rgb(body.getBottomBorderXSSFColor()));
assertEquals(26F, sheet.getRow(201).getHeightInPoints());
assertEquals("F3F6FA", fill(sheet, 200));
assertEquals("FFFFFF", fill(sheet, 201));
assertEquals("F3F6FA", fill(sheet, 202));
assertEquals(sheet.getRow(1).getCell(0).getCellStyle().getIndex(),
sheet.getRow(401).getCell(0).getCellStyle().getIndex());
assertTrue(sheet.getPaneInformation().isFreezePane());
assertEquals(1, sheet.getPaneInformation().getHorizontalSplitPosition());
assertEquals("A1:J402", ((XSSFSheet) sheet).getCTWorksheet().getAutoFilter().getRef());
assertFalse(sheet.isDisplayGridlines());
assertEquals(24 * 256, sheet.getColumnWidth(0));
assertEquals(32 * 256, sheet.getColumnWidth(5));
assertEquals(28 * 256, sheet.getColumnWidth(6));
assertTrue(after.getNumCellStyles() < 20);
}
}
@Test
void configuredTokensAreBoundAndFixedWhenSubmittingTask() throws Exception {
ExcelConfigProperties properties = configuredProperties();
Harness harness = new Harness(properties);
harness.submit();
// 已排队的任务保留提交时的样式,不受后续配置对象修改影响。
properties.getExportStyle().getHeader().setFontName("Changed");
properties.getExportStyle().getBody().setFontSize(20);
properties.getExportStyle().getColumnWidths().put("username", 60D);
try (XSSFWorkbook workbook = workbook(harness.run())) {
XSSFSheet sheet = workbook.getSheetAt(0);
XSSFCellStyle head = sheet.getRow(0).getCell(0).getCellStyle();
assertEquals("Arial", head.getFont().getFontName());
assertEquals(14, head.getFont().getFontHeightInPoints());
assertFalse(head.getFont().getBold());
assertEquals("102030", rgb(head.getFont().getXSSFColor()));
assertEquals("DCEEFF", rgb(head.getFillForegroundXSSFColor()));
assertEquals(42F, sheet.getRow(0).getHeightInPoints());
assertEquals(HorizontalAlignment.LEFT, head.getAlignment());
assertFalse(head.getWrapText());
XSSFCellStyle body = sheet.getRow(1).getCell(0).getCellStyle();
assertEquals("宋体", body.getFont().getFontName());
assertEquals(12, body.getFont().getFontHeightInPoints());
assertTrue(body.getFont().getBold());
assertEquals("203040", rgb(body.getFont().getXSSFColor()));
assertEquals("FFFFEE", rgb(body.getFillForegroundXSSFColor()));
assertEquals(30F, sheet.getRow(1).getHeightInPoints());
assertEquals(HorizontalAlignment.CENTER, body.getAlignment());
assertTrue(body.getWrapText());
assertEquals(0, body.getIndention());
assertEquals(BorderStyle.DASHED, body.getBorderBottom());
assertEquals("112244", rgb(body.getBottomBorderXSSFColor()));
assertEquals("E5EEE9", fill(sheet, 2));
assertEquals(27 * 256, sheet.getColumnWidth(0));
assertEquals((int) (35.5 * 256), sheet.getColumnWidth(1));
assertNull(sheet.getPaneInformation());
assertFalse(sheet.getCTWorksheet().isSetAutoFilter());
assertTrue(sheet.isDisplayGridlines());
}
}
@Test
void synchronousExportIgnoresGlobalTokens() throws Exception {
byte[] original = new Harness(new ExcelConfigProperties()).synchronous();
byte[] configured = new Harness(configuredProperties()).synchronous();
assertArrayEquals(zipEntry(original, "xl/styles.xml"), zipEntry(configured, "xl/styles.xml"));
assertArrayEquals(zipEntry(original, "xl/worksheets/sheet1.xml"),
zipEntry(configured, "xl/worksheets/sheet1.xml"));
}
@Test
void reusableHandlerPreservesNumberFormatsAndBoundsStyleCountForManyOddSizedBatches() throws Exception {
ExcelStyleTokens tokens = new ExcelStyleTokens();
tokens.setStriped(false);
try (XSSFWorkbook plain = workbook(amounts(7, tokens));
XSSFWorkbook striped = workbook(amounts(20_001, new ExcelStyleTokens()))) {
Sheet sheet = striped.getSheetAt(0);
assertEquals(20_001, sheet.getLastRowNum());
assertEquals("FFFFFF", fill(plain.getSheetAt(0), 2));
assertEquals("FFFFFF", fill(sheet, 101));
assertEquals("F3F6FA", fill(sheet, 102));
for (int row : List.of(1, 101, 102, 20_001)) {
Cell amount = sheet.getRow(row).getCell(0);
assertEquals(CellType.NUMERIC, amount.getCellType());
assertEquals(1234.5678, amount.getNumericCellValue(), 0.0000001);
assertEquals("0.0000", amount.getCellStyle().getDataFormatString());
}
// 多出的数万行只增加一种条纹样式,字体数不随行数增长。
assertEquals(plain.getNumCellStyles() + 1, striped.getNumCellStyles());
assertEquals(plain.getNumberOfFonts(), striped.getNumberOfFonts());
assertTrue(striped.getNumCellStyles() < 20);
}
}
private static ExcelConfigProperties configuredProperties() {
Map<String, Object> values = new HashMap<>();
String prefix = "excel.export-style.";
Map<String, Object> tokens = Map.ofEntries(
Map.entry("header.font-name", "Arial"), Map.entry("header.font-size", 14),
Map.entry("header.bold", false), Map.entry("header.font-color", "#102030"),
Map.entry("header.background-color", "#DCEEFF"), Map.entry("header.row-height", 42),
Map.entry("header.alignment", "LEFT"), Map.entry("header.wrap-text", false),
Map.entry("body.font-name", "宋体"), Map.entry("body.font-size", 12),
Map.entry("body.bold", true), Map.entry("body.font-color", "203040"),
Map.entry("body.background-color", "#FFFFEE"), Map.entry("body.row-height", 30),
Map.entry("body.alignment", "CENTER"), Map.entry("body.wrap-text", true),
Map.entry("body.indent", 0), Map.entry("alternate-row-color", "#E5EEE9"),
Map.entry("border-style", "DASHED"), Map.entry("border-color", "#112244"),
Map.entry("column-widths.[userId]", 27D), Map.entry("column-widths.[username]", 35.5D),
Map.entry("freeze-header", false), Map.entry("auto-filter", false),
Map.entry("show-grid-lines", true));
tokens.forEach((key, value) -> values.put(prefix + key, value));
return new Binder(new MapConfigurationPropertySource(values))
.bind("excel", Bindable.of(ExcelConfigProperties.class)).get();
}
private static byte[] amounts(int total, ExcelStyleTokens tokens) {
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (ExcelWriter writer = EasyExcel.write(output).head(AmountRow.class)
.registerWriteHandler(new ExcelExportStyleHandler(tokens)).build()) {
WriteSheet sheet = EasyExcel.writerSheet("amounts").build();
for (int from = 0; from < total; from += 101) {
writer.write(Collections.nCopies(Math.min(101, total - from),
new AmountRow(new BigDecimal("1234.5678"))), sheet);
}
}
return output.toByteArray();
}
@Data
@AllArgsConstructor
public static class AmountRow {
@ExcelProperty("金额")
@NumberFormat("0.0000")
private BigDecimal amount;
}
private static class Harness {
private final SysUserMapper mapper = mock(SysUserMapper.class);
private final SysFileService files = mock(SysFileService.class);
private final AsyncTaskExecutorService tasks = mock(AsyncTaskExecutorService.class);
private final SysUserBatchExportService service;
private byte[] uploaded;
private AsyncTaskResultAction action;
private Harness(ExcelConfigProperties properties) {
when(mapper.selectVoExportPageByScope(any(), any(), isNull(), any())).thenAnswer(call -> {
Page<UserVO> page = call.getArgument(0);
List<UserVO> rows = new ArrayList<>();
long start = (page.getCurrent() - 1) * page.getSize();
for (long index = start; index < Math.min(start + page.getSize(), 401); index++) {
UserVO user = new UserVO();
user.setUserId(90071992547409930L + index);
user.setUsername("用户" + index);
user.setPhone("13800001111");
user.setNickname("昵称" + index);
user.setName("张三");
user.setEmail("user" + index + "@example.com");
user.setDeptName("示例部门");
SysRole role = new SysRole(); role.setRoleName("角色甲");
SysRole another = new SysRole(); another.setRoleName("角色乙");
SysPost post = new SysPost(); post.setPostName("示例岗位");
user.setRoleList(List.of(role, another));
user.setPostList(List.of(post));
user.setStaffingType(UserStaffingTypeEnum.values()[0].getType());
user.setLockFlag("0");
user.setCreateTime(LocalDateTime.of(2026, 10, 21, 10, 20, 30));
rows.add(user);
}
return new Page<UserVO>(page.getCurrent(), page.getSize()).setTotal(401).setRecords(rows);
});
when(files.uploadFile(any(), eq("export"), isNull(), eq("excel"))).thenAnswer(call -> {
MultipartFile file = call.getArgument(0);
uploaded = file.getBytes();
return R.ok(Map.of("url", "https://files.example.com/users.xlsx"));
});
service = new SysUserBatchExportService(mapper, files, tasks, properties);
}
private void submit() {
service.submit(new UserDTO());
ArgumentCaptor<AsyncTaskResultAction> captor = ArgumentCaptor.forClass(AsyncTaskResultAction.class);
verify(tasks).submitWithResult(eq("admin:export:user"), eq("用户导出"), eq(1), captor.capture());
action = captor.getValue();
}
private byte[] run() throws Exception {
AsyncTaskResult result = action.execute(mock(AsyncTaskProgressReporter.class));
assertEquals("用户管理.xlsx", result.fileName());
assertEquals("https://files.example.com/users.xlsx", result.fileUrl());
return uploaded;
}
private byte[] synchronous() throws Exception {
MockHttpServletResponse response = new MockHttpServletResponse();
service.export(response, new UserDTO());
return response.getContentAsByteArray();
}
}
private static XSSFWorkbook workbook(byte[] bytes) throws Exception {
return new XSSFWorkbook(new ByteArrayInputStream(bytes));
}
private static void assertContentEquals(Sheet before, Sheet after) {
assertEquals(before.getLastRowNum(), after.getLastRowNum());
Map<String, Integer> originalColumns = new HashMap<>();
before.getRow(0).forEach(cell -> originalColumns.put(cell.getStringCellValue(), cell.getColumnIndex()));
for (Row row : before) {
Row actualRow = after.getRow(row.getRowNum());
assertEquals(10, actualRow.getLastCellNum());
for (Cell header : after.getRow(0)) {
Cell cell = row.getCell(originalColumns.get(header.getStringCellValue()));
Cell actual = actualRow.getCell(header.getColumnIndex());
assertEquals(cell.getCellType(), actual.getCellType());
assertEquals(cell.toString(), actual.toString());
assertEquals(cell.getCellStyle().getDataFormatString(), actual.getCellStyle().getDataFormatString());
}
}
}
private static String fill(Sheet sheet, int row) {
return rgb(((XSSFCellStyle) sheet.getRow(row).getCell(0).getCellStyle()).getFillForegroundXSSFColor());
}
private static String rgb(XSSFColor color) {
return HexFormat.of().withUpperCase().formatHex(color.getRGB());
}
private static byte[] zipEntry(byte[] bytes, String name) throws Exception {
Path path = Files.createTempFile("user-export-style-test-", ".xlsx");
try {
Files.write(path, bytes);
try (ZipFile zip = new ZipFile(path.toFile())) {
var entry = zip.getEntry(name);
assertNotNull(entry, "缺少 XLSX 项: " + name);
try (InputStream input = zip.getInputStream(entry)) {
return input.readAllBytes();
}
}
} finally {
Files.deleteIfExists(path);
}
}
}
@@ -13,6 +13,11 @@
<artifactId>pjcloud-wallet-biz</artifactId>
<dependencies>
<!-- 通用异步导出及租户隔离的任务进度。 -->
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-async-task</artifactId>
</dependency>
<!-- 分布式锁 -->
<dependency>
<groupId>com.baomidou</groupId>
@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.constant.CacheConstants;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum;
import com.bsd.pjcloud.common.log.annotation.SysLog;
@@ -23,6 +24,7 @@ import com.bsd.pjcloud.wallet.api.vo.WalletQbTotalVo;
import com.bsd.pjcloud.wallet.mapper.WalletManageMapper;
import com.bsd.pjcloud.wallet.service.WalletCodeUseService;
import com.bsd.pjcloud.wallet.service.WalletManageService;
import com.bsd.pjcloud.wallet.service.export.WalletManageBatchExportService;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -50,6 +52,7 @@ public class WalletManageController {
private final WalletCodeUseService walletCodeUseService;
private final WalletManageMapper walletManageMapper;
private final WalletManageBatchExportService batchExportService;
/* ************************************* start android start ************************************* */
@@ -323,13 +326,25 @@ public class WalletManageController {
@PostMapping("/export")
@PreAuthorize("@pms.hasPermission('wallet_walletManage_export')")
public void export(HttpServletResponse response, @ParameterObject Page page, @RequestBody WalletManageDto dto) {
public void export(HttpServletResponse response, @ParameterObject Page page, @RequestBody WalletManageDto dto) throws Exception {
walletManageService.export(response, page, dto);
}
@PostMapping("/export-async")
@PreAuthorize("@pms.hasPermission('wallet_walletManage_export')")
public R<AsyncTaskProgressVO> exportAsync(@RequestBody WalletManageDto dto) {
return R.ok(batchExportService.submit(dto), "导出任务已提交");
}
@PostMapping("/export-qb")
// @PreAuthorize("@pms.hasPermission('wallet_walletManage_export')")
public void exportQb(HttpServletResponse response, @ParameterObject Page page, @RequestBody WalletManageBalanceDto dto) {
public void exportQb(HttpServletResponse response, @ParameterObject Page page, @RequestBody WalletManageBalanceDto dto) throws Exception {
walletManageService.exportQb(response, page, dto);
}
}
/** 与原接口保持相同权限,完成后由任务结果返回附件地址。 */
@PostMapping("/export-qb-async")
public R<AsyncTaskProgressVO> exportQbAsync(@RequestBody WalletManageBalanceDto dto) {
return R.ok(batchExportService.submitQb(dto), "导出任务已提交");
}
}
@@ -49,6 +49,9 @@ import java.util.concurrent.TimeUnit;
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class WalletTransactionController {
/** 保留旧模板的分页异步导出。 */
private final com.bsd.pjcloud.wallet.service.export.WalletBatchExportService batchExportService;
private final WalletTransactionService walletTransactionService;
private final WalletTransactionMapper walletTransactionMapper;
@@ -287,14 +290,14 @@ public class WalletTransactionController {
@Operation(summary = "充值导出", description = "充值导出")
@PostMapping("/export-cz")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public void exportCz(HttpServletResponse response, @RequestBody WalletTransactionDto dto) {
dto.setBizTypes(WalletBizEnum.getTopUpCodes());
dto.setType(WalletTransactionDto.TYPE_INCOME);
public void exportCz(HttpServletResponse response, @RequestBody WalletTransactionDto dto) throws Exception {
batchExportService.transaction(response, dto, "cz");
}
DataScope dataScope = null;
Long count = walletTransactionMapper.count(dto, dataScope);
if (count > 3 * 10000) throw new RuntimeException("导出数据超出30000条,请缩小查询范围");
walletTransactionService.exportByTemplate(response, dto, count, "file/个人充值明细.xlsx", dataScope);
@PostMapping("/export-cz-async")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public R<com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO> exportCzAsync(@RequestBody WalletTransactionDto dto) {
return R.ok(batchExportService.submitTransaction(dto, "cz"), "导出任务已提交");
}
@Operation(summary = "充值统计", description = "充值统计")
@@ -309,15 +312,14 @@ public class WalletTransactionController {
@Operation(summary = "记账扣款导出", description = "记账扣款导出")
@PostMapping("/export-jz")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public void exportJz(HttpServletResponse response, @RequestBody WalletTransactionDto dto) {
dto.setBizTypes(WalletBizEnum.getTopUpCodes());
dto.setType(WalletTransactionDto.TYPE_EXPENSE);
public void exportJz(HttpServletResponse response, @RequestBody WalletTransactionDto dto) throws Exception {
batchExportService.transaction(response, dto, "jz");
}
DataScope dataScope = null;
Long count = walletTransactionMapper.count(dto, dataScope);
if (count > 3 * 10000) throw new RuntimeException("导出数据超出30000条,请缩小查询范围");
walletTransactionService.exportByTemplate(response, dto, count, "file/个人记账扣款明细.xlsx", dataScope);
@PostMapping("/export-jz-async")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public R<com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO> exportJzAsync(@RequestBody WalletTransactionDto dto) {
return R.ok(batchExportService.submitTransaction(dto, "jz"), "导出任务已提交");
}
@Operation(summary = "记账扣款统计", description = "记账扣款统计")
@@ -333,14 +335,15 @@ public class WalletTransactionController {
@Operation(summary = "消费导出", description = "消费导出")
@PostMapping("/export-xf")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public void exportXf(HttpServletResponse response, @RequestBody WalletTransactionDto dto) {
dto.setBizTypes(List.of(WalletBizEnum.PAYMENT.getCode()));
public void exportXf(HttpServletResponse response, @RequestBody WalletTransactionDto dto) throws Exception {
batchExportService.transaction(response, dto, "xf");
}
DataScope dataScope = null;
Long count = walletTransactionMapper.count(dto, dataScope);
if (count > 3 * 10000) throw new RuntimeException("导出数据超出30000条,请缩小查询范围");
walletTransactionService.exportByTemplate(response, dto, count, "file/个人消费明细.xlsx", dataScope);
/** 提交xf流水导出,成功终态才返回下载地址。 */
@PostMapping("/export-xf-async")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public R<com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO> exportXfAsync(@RequestBody WalletTransactionDto dto) {
return R.ok(batchExportService.submitTransaction(dto, "xf"), "导出任务已提交");
}
@Operation(summary = "消费统计", description = "消费统计")
@@ -354,15 +357,15 @@ public class WalletTransactionController {
@Operation(summary = "退款导出", description = "退款导出")
@PostMapping("/export-tk")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public void exportTk(HttpServletResponse response, @RequestBody WalletTransactionDto dto) {
dto.setBizTypes(List.of(WalletBizEnum.REFUND.getCode()));
dto.setIsFindOrder(true);
public void exportTk(HttpServletResponse response, @RequestBody WalletTransactionDto dto) throws Exception {
batchExportService.transaction(response, dto, "tk");
}
DataScope dataScope = null;
Long count = walletTransactionMapper.count(dto, dataScope);
if (count > 3 * 10000) throw new RuntimeException("导出数据超出30000条,请缩小查询范围");
walletTransactionService.exportByTemplate(response, dto, count, "file/个人退款明细.xlsx", dataScope);
/** 提交tk流水导出,成功终态才返回下载地址。 */
@PostMapping("/export-tk-async")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public R<com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO> exportTkAsync(@RequestBody WalletTransactionDto dto) {
return R.ok(batchExportService.submitTransaction(dto, "tk"), "导出任务已提交");
}
@Operation(summary = "退款统计", description = "退款统计")
@@ -377,15 +380,15 @@ public class WalletTransactionController {
@Operation(summary = "后台退还各自食堂记录导出", description = "后台退还各自食堂记录导出")
@PostMapping("/export-th")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public void exportTh(HttpServletResponse response, @RequestBody WalletTransactionDto dto) {
dto.setBizTypes(List.of(WalletBizEnum.RETURN_CANTEEN.getCode()));
dto.setIsFindOrder(false);
public void exportTh(HttpServletResponse response, @RequestBody WalletTransactionDto dto) throws Exception {
batchExportService.transaction(response, dto, "th");
}
DataScope dataScope = null;
Long count = walletTransactionMapper.count(dto, dataScope);
if (count > 3 * 10000) throw new RuntimeException("导出数据超出30000条,请缩小查询范围");
walletTransactionService.exportByTemplate(response, dto, count, "file/退还各自食堂明细.xlsx", dataScope);
/** 提交th流水导出,成功终态才返回下载地址。 */
@PostMapping("/export-th-async")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public R<com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO> exportThAsync(@RequestBody WalletTransactionDto dto) {
return R.ok(batchExportService.submitTransaction(dto, "th"), "导出任务已提交");
}
@Operation(summary = "退还各自食堂统计", description = "退还各自食堂统计")
@@ -397,4 +400,4 @@ public class WalletTransactionController {
return R.ok(walletTransactionService.walletTypetotal(dto, dataScope));
}
}
}
@@ -45,6 +45,9 @@ import java.util.List;
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class WalletRechargeApplyController {
/** 共用原模板的分批导出服务。 */
private final com.bsd.pjcloud.wallet.service.export.WalletBatchExportService batchExportService;
private final WalletRechargeApplyService walletRechargeApplyService;
private final WalletRechargeApplyComposeService walletTransactionComposeService;
@@ -165,11 +168,17 @@ public class WalletRechargeApplyController {
/**
* 导出充值/扣款申请
*/
@ResponseExcel
@PostMapping("/export")
@PreAuthorize("@pms.hasPermission('wallet_walletRechargeApply_export')")
public void export(HttpServletResponse response, @RequestBody WalletRechargeApplyDto dto) {
walletRechargeApplyService.export(response, dto);
public void export(HttpServletResponse response, @RequestBody WalletRechargeApplyDto dto) throws Exception {
batchExportService.rechargeApply(response, dto);
}
/** 按当前筛选条件提交后台分批导出。 */
@PostMapping("/export-async")
@PreAuthorize("@pms.hasPermission('wallet_walletRechargeApply_export')")
public R<com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO> exportAsync(@RequestBody WalletRechargeApplyDto dto) {
return R.ok(batchExportService.submitApply(dto), "导出任务已提交");
}
@PostMapping("/read-excel")
@@ -55,6 +55,9 @@ import java.util.Map;
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class WalletRechargeImportController {
/** 共用原模板的分批导出服务。 */
private final com.bsd.pjcloud.wallet.service.export.WalletBatchExportService batchExportService;
private final WalletRechargeImportService walletRechargeImportService;
private final WalletRechargeImportMapper walletRechargeImportMapper;
@@ -112,50 +115,16 @@ public class WalletRechargeImportController {
return R.ok(walletRechargeImportService.removeBatchByIds(CollUtil.toList(ids)));
}
@ResponseExcel
@PostMapping("/export")
@PreAuthorize("@pms.hasPermission('wallet_walletRechargeApply_export')")
public void export(HttpServletResponse response, @RequestBody WalletRechargeImportDto dto) throws IOException {
DataScope dataScope = DataScope.of();
Long count = walletRechargeImportMapper.count(dto, dataScope);
if (count > 5000) throw new RuntimeException("导出数据超出5000条,请缩小查询范围");
List<WalletRechargeImportVo> list = walletRechargeImportService.scopeList(dto, dataScope);
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<String, Object> map = new HashMap<String, Object>();
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 priceStrSum = BigDecimal.ZERO;
Long lineNumber = 1L;
for (WalletRechargeImportVo v : list) {
v.setLineNumber(lineNumber);
lineNumber++;
if (ObjectUtil.isNotEmpty(v.getPrice())) {
if (WalletBizRechargeEnum.reductionCodes().contains(v.getBizType()) && v.getPrice() > 0) {
v.setPrice(v.getPrice() * -1);
}
priceStrSum = priceStrSum.add(BigDecimal.valueOf(v.getPrice()));
v.setPriceStr(BigDecimal.valueOf(v.getPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
}
if (ObjectUtil.isNotEmpty(v.getImportTime())) {
v.setImportTimeStr(LocalDateTimeUtil.format(v.getImportTime(), DatePattern.NORM_MONTH_PATTERN));
}
if (ObjectUtil.isNotEmpty(v.getBizType())) {
v.setBizType(WalletBizRechargeEnum.valueOfByType(v.getBizType()).getDesc());
}
if (ObjectUtil.isNotEmpty(v.getWalletTypeUniqueCode())) {
v.setWalletTypeUniqueCode(WalletTypeEnum.valueOfByCode(v.getWalletTypeUniqueCode()).getDesc());
}
}
map.put("priceStrSum", priceStrSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
}
excelWriter.fill(map, writeSheet);
excelWriter.fill(list, fillConfig, writeSheet);
}
public void export(HttpServletResponse response, @RequestBody WalletRechargeImportDto dto) throws Exception {
batchExportService.rechargeImport(response, dto);
}
/** 按当前筛选条件提交后台分批导出。 */
@PostMapping("/export-async")
@PreAuthorize("@pms.hasPermission('wallet_walletRechargeApply_export')")
public R<com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO> exportAsync(@RequestBody WalletRechargeImportDto dto) {
return R.ok(batchExportService.submitImport(dto), "导出任务已提交");
}
}
@@ -37,6 +37,9 @@ import org.springframework.web.bind.annotation.*;
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class WalletRechargeInvoiceController {
/** 共用原模板的分批导出服务。 */
private final com.bsd.pjcloud.wallet.service.export.WalletBatchExportService batchExportService;
private final WalletRechargeInvoiceService walletRechargeInvoiceService;
/* ************************************* start callback start ************************************* */
@@ -138,10 +141,16 @@ public class WalletRechargeInvoiceController {
return R.ok();
}
@ResponseExcel
@PostMapping("/export")
@PreAuthorize("@pms.hasPermission('wallet_walletRechargeInvoice_export')")
public void export(HttpServletResponse response, @ParameterObject Page page, @RequestBody WalletRechargeBindInvoiceDto dto) {
walletRechargeInvoiceService.export(response, page, dto, DataScope.of());
public void export(HttpServletResponse response, @ParameterObject Page page, @RequestBody WalletRechargeBindInvoiceDto dto) throws Exception {
batchExportService.invoice(response, dto, page);
}
/** 按当前筛选条件提交后台分批导出。 */
@PostMapping("/export-async")
@PreAuthorize("@pms.hasPermission('wallet_walletRechargeInvoice_export')")
public R<com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO> exportAsync(@ParameterObject Page page, @RequestBody WalletRechargeBindInvoiceDto dto) {
return R.ok(batchExportService.submitInvoice(dto, page), "导出任务已提交");
}
}
@@ -156,6 +156,13 @@ public interface WalletManageMapper extends PjcloudBaseMapper<WalletManageEntity
return list;
}
/** 保持原筛选和排序,并用钱包主键消除并列记录的分页顺序歧义。 */
default Page<WalletManageVo> scopeExportPage(Page page, WalletManageDto dto, DataScope dataScope) {
Page list = selectPageByScope(page, commonQuery(dto).orderByDesc(WalletManageEntity::getId), dataScope);
list.setRecords(BeanUtils.toBean(list.getRecords(), WalletManageVo.class));
return list;
}
default Long count(WalletManageDto dto, DataScope dataScope) {
return selectCountByScope(commonQuery(dto), dataScope);
}
@@ -171,9 +178,12 @@ public interface WalletManageMapper extends PjcloudBaseMapper<WalletManageEntity
List<WalletManageBalanceVo> selectBalance(@Param("dto") WalletManageBalanceDto walletDto);
/** 复用历史余额查询口径,流水结果也按固定批大小读取。 */
Page<WalletManageBalanceVo> selectBalanceExportPage(Page page, @Param("dto") WalletManageBalanceDto walletDto);
Page<WalletManageVo> activeBalancePage(Page page, @Param("dto") WalletTopupActionDto dto, @Param("unactiveDayNumberStr") String unactiveDayNumberStr);
List<WalletManageVo> activeBalanceList(@Param("dto") WalletTopupActionDto dto, @Param("unactiveDayNumberStr") String unactiveDayNumberStr);
Long selectTotalBalance(@Param("dto") WalletManageBalanceDto dto);
}
}
@@ -73,10 +73,16 @@ public interface WalletManageService extends IService<WalletManageEntity> {
Page<WalletManageVo> scopePageList(Page page, WalletManageDto dto, DataScope dataScope);
/** 导出专用分页,稳定排序,单批补全用户、部门及饭卡类型。 */
Page<WalletManageVo> scopeExportPageList(Page page, WalletManageDto dto, DataScope dataScope);
List<WalletManageVo> scopeList(WalletManageDto dto, DataScope dataScope);
Page<WalletManageVo> scopeQbPage(Page page, WalletManageBalanceDto dto, DataScope dataScope);
/** 历史钱包余额导出:钱包和关联流水均分页,不修改请求中的月份和筛选。 */
Page<WalletManageVo> scopeQbExportPage(Page page, WalletManageBalanceDto dto, DataScope dataScope);
WalletQbTotalVo qbTotal(WalletManageBalanceDto dto, DataScope dataScope);
/**
@@ -103,12 +109,12 @@ public interface WalletManageService extends IService<WalletManageEntity> {
/**
* 导出
*/
void export(HttpServletResponse response, Page page, WalletManageDto dto);
void export(HttpServletResponse response, Page page, WalletManageDto dto) throws Exception;
/**
* 钱包记录导出
*/
void exportQb(HttpServletResponse response, Page page, WalletManageBalanceDto dto);
void exportQb(HttpServletResponse response, Page page, WalletManageBalanceDto dto) throws Exception;
/**
* 修改用户
@@ -134,4 +140,4 @@ public interface WalletManageService extends IService<WalletManageEntity> {
* 删除钱包缓存
*/
void delWalletCache(WalletManageVo wallet);
}
}
@@ -38,6 +38,9 @@ public interface WalletRechargeApplyService extends IService<WalletRechargeApply
Page<WalletRechargeApplyVo> pageJoin(Page page, WalletRechargeApplyDto dto, DataScope dataScope);
/** 后台导出分页,原字段补全在调用线程内完成。 */
Page<WalletRechargeApplyVo> scopeExportPageList(Page page, WalletRechargeApplyDto dto, DataScope dataScope);
Page<WalletRechargeApplyVo> scopePageList(Page page, WalletRechargeApplyDto dto, DataScope dataScope);
/**
@@ -16,6 +16,9 @@ public interface WalletRechargeImportService extends IService<WalletRechargeImpo
List<WalletRechargeImportVo> listJoin(WalletRechargeImportDto dto);
/** 导出专用分页,远程资料补全失败时终止导出。 */
Page<WalletRechargeImportVo> scopeExportPageList(Page page, WalletRechargeImportDto dto, DataScope dataScope);
Page<WalletRechargeImportVo> scopePageList(Page page, WalletRechargeImportDto dto, DataScope dataScope);
List<WalletRechargeImportVo> scopeList(WalletRechargeImportDto dto, DataScope dataScope);
@@ -26,6 +26,9 @@ public interface WalletRechargeInvoiceService extends IService<WalletRechargeInv
/**
* 分页查询
*/
/** 导出专用分页,远程资料补全失败时终止导出。 */
Page<WalletRechargeInvoiceVo> scopeExportPageList(Page page, WalletRechargeBindInvoiceDto dto, DataScope dataScope);
Page<WalletRechargeInvoiceVo> scopePageList(Page page, WalletRechargeBindInvoiceDto dto, DataScope dataScope);
/**
@@ -19,6 +19,9 @@ import java.util.List;
public interface WalletTransactionService extends IService<WalletTransactionEntity> {
/** 导出专用分页,远程资料补全失败时终止导出。 */
Page<WalletTransactionVo> scopeExportPageList(Page page, WalletTransactionDto dto, DataScope dataScope);
Page<WalletTransactionVo> scopePageList(Page page, WalletTransactionDto dto, DataScope dataScope);
/**
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more