111
This commit is contained in:
1 parent
c34b249d7f
commit
f4c4390352
114 files changed
+8278
-631
No files matched your search
@@ -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秒。
|
||||
@@ -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>
|
||||
+95
@@ -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);
|
||||
}
|
||||
}
|
||||
+33
@@ -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);
|
||||
}
|
||||
}
|
||||
+40
@@ -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;
|
||||
}
|
||||
}
|
||||
+50
@@ -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);
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.bsd.pjcloud.common.asynctask.service;
|
||||
|
||||
/**
|
||||
* 通用异步任务业务动作。
|
||||
* <p>动作在后台事务中执行,抛出异常会回滚整个动作并把 Redis 任务标记为失败。</p>
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface AsyncTaskAction {
|
||||
|
||||
/**
|
||||
* 执行具体异步业务。
|
||||
*
|
||||
* @param reporter 逐项进度报告器,业务完成一个计数项目后调用一次
|
||||
*/
|
||||
void execute(AsyncTaskProgressReporter reporter);
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.bsd.pjcloud.common.asynctask.service;
|
||||
|
||||
/** 允许展示给任务发起人的受控错误;禁止传入 SQL、Token 或底层异常文本。 */
|
||||
public class AsyncTaskException extends RuntimeException {
|
||||
/** 使用业务可读提示终止任务,不暴露底层异常。 */
|
||||
public AsyncTaskException(String message) { super(message); }
|
||||
}
|
||||
+34
@@ -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("当前异步任务执行器不支持返回任务结果");
|
||||
}
|
||||
}
|
||||
+27
@@ -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);
|
||||
}
|
||||
+91
@@ -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);
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
}
|
||||
+17
@@ -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;
|
||||
}
|
||||
+99
@@ -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;
|
||||
}
|
||||
}
|
||||
+217
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+262
@@ -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);
|
||||
}
|
||||
}
|
||||
+66
@@ -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;
|
||||
}
|
||||
+2
@@ -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,缓存工作簿中的字体与单元格样式,条纹按实际行号计算,适用于分批流式写入。现有转换器、单元格类型、数据格式及导出列保持原样。固定格式模板应继续使用自身样式,不自动注册此处理器。
|
||||
+14
@@ -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();
|
||||
|
||||
}
|
||||
+617
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -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); }
|
||||
}
|
||||
+182
@@ -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) { }
|
||||
}
|
||||
+193
@@ -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;
|
||||
}
|
||||
}
|
||||
+102
@@ -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;
|
||||
}
|
||||
}
|
||||
+36
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+30
@@ -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;
|
||||
}
|
||||
+67
@@ -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) {
|
||||
}
|
||||
}
|
||||
+14
@@ -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;
|
||||
}
|
||||
+77
@@ -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
-1
@@ -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;
|
||||
|
||||
}
|
||||
+37
-102
@@ -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), "导出任务已提交");
|
||||
}
|
||||
}
|
||||
+29
-213
@@ -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), "导出任务已提交");
|
||||
}
|
||||
}
|
||||
+63
@@ -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()));
|
||||
}
|
||||
}
|
||||
+3
@@ -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);
|
||||
|
||||
+20
@@ -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);
|
||||
}
|
||||
+15
@@ -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;
|
||||
}
|
||||
+7
@@ -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) {
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
+380
@@ -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);
|
||||
}
|
||||
}
|
||||
+103
-10
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
+161
@@ -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));
|
||||
}
|
||||
}
|
||||
+37
-16
@@ -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;
|
||||
|
||||
+33
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -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());
|
||||
}
|
||||
}
|
||||
+116
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -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'
|
||||
|
||||
+146
-59
@@ -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 > 0 and a.refund_price < a.pay_price
|
||||
</when>
|
||||
<when test="dto.thirdRefundState == 'FULL'">
|
||||
and a.refund_price > 0 and a.refund_price >= 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 > 0</when>
|
||||
<otherwise>and (a.extra_price is null or a.extra_price <= 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,
|
||||
|
||||
+67
@@ -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>
|
||||
+118
@@ -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;
|
||||
}
|
||||
}
|
||||
+223
@@ -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 + " 字号");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -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()));
|
||||
}
|
||||
}
|
||||
+68
@@ -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());
|
||||
}
|
||||
}
|
||||
+70
@@ -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();
|
||||
}
|
||||
}
|
||||
+272
@@ -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();
|
||||
}
|
||||
}
|
||||
+63
@@ -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());
|
||||
}
|
||||
}
|
||||
+190
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -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));
|
||||
}
|
||||
}
|
||||
+114
@@ -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());
|
||||
}
|
||||
}
|
||||
+133
@@ -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());
|
||||
}
|
||||
}
|
||||
+318
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -15,6 +15,7 @@ import java.util.Arrays;
|
||||
public enum ThirdKeyTypeEnum {
|
||||
|
||||
LB("10", "宁波银行"),
|
||||
NFNCP("20", "农发农产品"),
|
||||
;
|
||||
|
||||
private final String type;
|
||||
|
||||
+1
@@ -14,6 +14,7 @@ import java.util.Arrays;
|
||||
public enum ThirdPaySoureBizTypeEnum {
|
||||
|
||||
LB("10", "宁波银行"),
|
||||
NFNCP("20", "农发农产品"),
|
||||
;
|
||||
|
||||
private final String type;
|
||||
|
||||
+5
@@ -10,4 +10,9 @@ public class ThirdPayLogDto extends ThirdPayLogEntity {
|
||||
|
||||
private LocalDate[] createTimeList;
|
||||
|
||||
/** 按 JSON 字段精确关联底单,避免请求全文模糊匹配到其他订单。 */
|
||||
private String relatedPlatformOrderId;
|
||||
|
||||
private String relatedThirdOrderId;
|
||||
|
||||
}
|
||||
+5
-22
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -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);
|
||||
|
||||
}
|
||||
+29
-5
@@ -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;
|
||||
}
|
||||
}
|
||||
+75
-1
@@ -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 + "'))";
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
+5
-3
@@ -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();
|
||||
|
||||
+44
@@ -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"));
|
||||
}
|
||||
}
|
||||
+101
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+13
-3
@@ -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), "用户导出任务已提交");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
+134
@@ -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,
|
||||
|
||||
+59
@@ -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());
|
||||
}
|
||||
}
|
||||
+330
@@ -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>
|
||||
|
||||
+18
-3
@@ -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), "导出任务已提交");
|
||||
}
|
||||
}
|
||||
+42
-39
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -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")
|
||||
|
||||
+12
-43
@@ -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), "导出任务已提交");
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -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), "导出任务已提交");
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
+3
@@ -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);
|
||||
|
||||
+3
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
+3
@@ -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
Reference in new issue
Block a user