diff --git a/db/1000pjcloudx_thirdparty.sql b/db/1000pjcloudx_thirdparty.sql index f1f6e2fe4..bab9f30cc 100644 --- a/db/1000pjcloudx_thirdparty.sql +++ b/db/1000pjcloudx_thirdparty.sql @@ -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 -- ---------------------------- diff --git a/db/20261004_wallet_transaction_page_index.sql b/db/20261004_wallet_transaction_page_index.sql new file mode 100644 index 000000000..5b76e0a88 --- /dev/null +++ b/db/20261004_wallet_transaction_page_index.sql @@ -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; diff --git a/db/20261021_hard_order_statistics_index.sql b/db/20261021_hard_order_statistics_index.sql new file mode 100644 index 000000000..87d318fa2 --- /dev/null +++ b/db/20261021_hard_order_statistics_index.sql @@ -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秒。 diff --git a/db/20261021_third_pay_log_page_index.sql b/db/20261021_third_pay_log_page_index.sql new file mode 100644 index 000000000..02c64139b --- /dev/null +++ b/db/20261021_third_pay_log_page_index.sql @@ -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; diff --git a/pjcloud-common/pjcloud-common-async-task/README.md b/pjcloud-common/pjcloud-common-async-task/README.md new file mode 100644 index 000000000..400ea0170 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/README.md @@ -0,0 +1,79 @@ +# 通用异步任务模块 + +`pjcloud-common-async-task` 提供可由各业务模块复用的异步事务执行、Redis 进度保存和统一进度查询接口。 + +## 接入方式 + +业务模块增加依赖后,Spring Boot 会自动装配 `AsyncTaskExecutorService`、`AsyncTaskProgressService` 和查询接口: + +```xml + + com.bsd + pjcloud-common-async-task + +``` + +提交任务时由业务方指定稳定的 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 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 小时,供页面刷新或短时重连后继续查询。 +- 任务进度按当前登录用户隔离,调用端不能查询其他用户创建的任务。 diff --git a/pjcloud-common/pjcloud-common-async-task/pom.xml b/pjcloud-common/pjcloud-common-async-task/pom.xml new file mode 100644 index 000000000..7637337e8 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/pom.xml @@ -0,0 +1,49 @@ + + + 4.0.0 + + com.bsd + pjcloud-common + 5.7.0 + + + pjcloud-common-async-task + jar + + pjcloud 通用异步任务与 Redis 进度管理 + + + + com.bsd + pjcloud-common-excel + + + + com.bsd + pjcloud-common-core + + + + com.bsd + pjcloud-common-data + + + + com.bsd + pjcloud-common-security + + + + org.springframework + spring-tx + + + + org.springframework + spring-webmvc + provided + + + diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/config/AsyncTaskAutoConfiguration.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/config/AsyncTaskAutoConfiguration.java new file mode 100644 index 000000000..24ad79eb8 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/config/AsyncTaskAutoConfiguration.java @@ -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; + +/** + * 通用异步任务自动配置。 + *

业务模块引入本公共模块后,在 Redis 和事务模板均可用时自动装配专用线程池、进度服务与事务执行器。

+ */ +@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 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); + } +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/config/AsyncTaskWebAutoConfiguration.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/config/AsyncTaskWebAutoConfiguration.java new file mode 100644 index 000000000..fb3cbd098 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/config/AsyncTaskWebAutoConfiguration.java @@ -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 接口自动配置。 + *

仅在 Web 应用已装配 Redis 进度服务时注册统一查询接口,非 Web 任务消费者不会引入无用端点。

+ */ +@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); + } +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/constant/AsyncTaskStatusEnum.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/constant/AsyncTaskStatusEnum.java new file mode 100644 index 000000000..99bac3c18 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/constant/AsyncTaskStatusEnum.java @@ -0,0 +1,40 @@ +package com.bsd.pjcloud.common.asynctask.constant; + +import lombok.Getter; + +/** + * 通用异步任务进度状态协议。 + *

状态编码会写入 Redis 并返回调用端,已有编码不得随意变更。

+ */ +@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; + } +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/controller/AsyncTaskProgressController.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/controller/AsyncTaskProgressController.java new file mode 100644 index 000000000..59644363a --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/controller/AsyncTaskProgressController.java @@ -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; + +/** + * 通用异步任务进度查询接口。 + *

任务业务 Key 按登录用户隔离,租户 ID 显式写入服务端任务键,只能查询本人创建且尚未过期的任务。

+ */ +@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 progress(@RequestParam String redisKey, @RequestParam String taskId) { + AsyncTaskProgressVO progress = asyncTaskProgressService.get(redisKey, taskId); + return progress == null ? R.failed("异步任务不存在或已过期") : R.ok(progress); + } +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskAction.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskAction.java new file mode 100644 index 000000000..ae64e038c --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskAction.java @@ -0,0 +1,16 @@ +package com.bsd.pjcloud.common.asynctask.service; + +/** + * 通用异步任务业务动作。 + *

动作在后台事务中执行,抛出异常会回滚整个动作并把 Redis 任务标记为失败。

+ */ +@FunctionalInterface +public interface AsyncTaskAction { + + /** + * 执行具体异步业务。 + * + * @param reporter 逐项进度报告器,业务完成一个计数项目后调用一次 + */ + void execute(AsyncTaskProgressReporter reporter); +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskException.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskException.java new file mode 100644 index 000000000..d2cedadd7 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskException.java @@ -0,0 +1,7 @@ +package com.bsd.pjcloud.common.asynctask.service; + +/** 允许展示给任务发起人的受控错误;禁止传入 SQL、Token 或底层异常文本。 */ +public class AsyncTaskException extends RuntimeException { + /** 使用业务可读提示终止任务,不暴露底层异常。 */ + public AsyncTaskException(String message) { super(message); } +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskExecutorService.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskExecutorService.java new file mode 100644 index 000000000..a4c866eb6 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskExecutorService.java @@ -0,0 +1,34 @@ +package com.bsd.pjcloud.common.asynctask.service; + +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; + +/** + * 通用异步事务任务提交服务。 + *

同步创建 Redis 任务后,把业务动作交给后台线程执行,并透传当前租户和登录用户上下文。

+ */ +public interface AsyncTaskExecutorService { + + /** + * 创建并提交一个异步事务任务。 + * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskName 任务展示名称 + * @param total 需要处理的总项目数 + * @param action 后台事务中执行的业务动作 + * @return 等待执行的初始任务进度,调用方可立即把任务 ID 返回客户端 + */ + AsyncTaskProgressVO submit(String redisKey, String taskName, int total, AsyncTaskAction action); + + /** + * 创建并提交一个可在成功终态携带结果文件的异步事务任务。 + * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskName 任务展示名称 + * @param total 需要处理的总项目数 + * @param action 后台事务中执行并返回结果文件信息的业务动作 + * @return 等待执行的初始任务进度,调用方可立即把任务 ID 返回客户端 + */ + default AsyncTaskProgressVO submitWithResult(String redisKey, String taskName, int total, AsyncTaskResultAction action) { + throw new UnsupportedOperationException("当前异步任务执行器不支持返回任务结果"); + } +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressReporter.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressReporter.java new file mode 100644 index 000000000..638b5c2c3 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressReporter.java @@ -0,0 +1,27 @@ +package com.bsd.pjcloud.common.asynctask.service; + +/** + * 异步任务逐项进度报告器。 + *

业务每完成一个可计数项目后调用一次,由通用执行器递增 Redis 进度。

+ */ +@FunctionalInterface +public interface AsyncTaskProgressReporter { + + /** + * 在后台任务取得真实数据量后更新总项目数。 + *

适用于查询本身耗时、必须先返回任务 ID 的导出场景;固定总数任务无需调用。

+ * + * @param total 后台查询得到的真实总项目数,必须大于零 + * @param message 总数更新后的进度说明;为空时使用通用说明 + */ + default void updateTotal(int total, String message) { + throw new UnsupportedOperationException("当前异步任务进度报告器不支持动态更新总数"); + } + + /** + * 报告一个项目已处理完成。 + * + * @param message 当前项目完成后的进度说明;为空时使用通用说明 + */ + void advance(String message); +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressService.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressService.java new file mode 100644 index 000000000..15e3d0584 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressService.java @@ -0,0 +1,91 @@ +package com.bsd.pjcloud.common.asynctask.service; + +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; + +/** + * 通用异步任务 Redis 进度服务。 + *

业务 Key 按当前用户隔离,租户 ID 显式写入服务端任务键;调用方只提供业务命名空间和任务 ID。

+ */ +public interface AsyncTaskProgressService { + + /** + * 创建等待执行的异步任务并写入 Redis。 + * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskName 任务展示名称 + * @param total 需要处理的总项目数,必须大于零 + * @return 已写入 Redis 的初始任务进度 + */ + AsyncTaskProgressVO create(String redisKey, String taskName, int total); + + /** + * 将任务标记为执行中。 + * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskId 不透明任务 ID + */ + void start(String redisKey, String taskId); + + /** + * 在任务开始后更新真实总项目数并重新计算完成百分比。 + * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskId 不透明任务 ID + * @param total 后台查询得到的真实总项目数,必须大于零 + * @param message 总数更新后的进度说明 + */ + default void updateTotal(String redisKey, String taskId, int total, String message) { + throw new UnsupportedOperationException("当前异步任务进度服务不支持动态更新总数"); + } + + /** + * 更新任务执行进度。 + * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskId 不透明任务 ID + * @param completed 当前已处理项目数 + * @param message 当前进度说明 + */ + void update(String redisKey, String taskId, int completed, String message); + + /** + * 将任务标记为成功并把进度置为 100%。 + * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskId 不透明任务 ID + * @param message 最终成功说明 + */ + void success(String redisKey, String taskId, String message); + + /** + * 将任务标记为成功,并在实现支持时保存业务动作返回的结果文件信息。 + *

默认实现退化为原有成功状态写入,保证既有自定义进度服务无需同步修改; + * 需要向调用端返回附件的实现应覆写本方法。

+ * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskId 不透明任务 ID + * @param message 最终成功说明 + * @param result 业务动作返回的可选结果文件信息 + */ + default void success(String redisKey, String taskId, String message, AsyncTaskResult result) { + success(redisKey, taskId, message); + } + + /** + * 将任务标记为失败并保留失败前执行进度。 + * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskId 不透明任务 ID + * @param message 对调用端安全的失败说明 + */ + void fail(String redisKey, String taskId, String message); + + /** + * 查询当前用户创建的异步任务进度。 + * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskId 不透明任务 ID + * @return 任务进度;任务不存在、已过期或不属于当前用户时返回 {@code null} + */ + AsyncTaskProgressVO get(String redisKey, String taskId); +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskResult.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskResult.java new file mode 100644 index 000000000..2b4f26b07 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskResult.java @@ -0,0 +1,40 @@ +package com.bsd.pjcloud.common.asynctask.service; + +/** + * 通用异步任务成功结果。 + *

导入等无附件任务可返回 {@link #empty()},导出任务可返回下载地址和文件名称;结果只会在任务成功终态写入进度对象。

+ * + * @param fileUrl 任务生成文件的下载地址;无结果文件时为 {@code null} + * @param fileName 下载时使用的业务文件名称;无结果文件时为 {@code null} + */ +public record AsyncTaskResult(String fileUrl, String fileName) { + + /** + * 创建通用异步任务成功结果。 + * + * @param fileUrl 任务生成文件的下载地址;无结果文件时为 {@code null} + * @param fileName 下载时使用的业务文件名称;无结果文件时为 {@code null} + */ + public AsyncTaskResult { + } + + /** + * 创建不携带结果文件的成功结果。 + * + * @return 无文件地址和名称的任务结果 + */ + public static AsyncTaskResult empty() { + return new AsyncTaskResult(null, null); + } + + /** + * 创建携带下载文件的成功结果。 + * + * @param fileUrl 项目现有文件中心返回的文件下载地址 + * @param fileName 下载时使用的业务文件名称 + * @return 包含结果文件信息的任务结果 + */ + public static AsyncTaskResult file(String fileUrl, String fileName) { + return new AsyncTaskResult(fileUrl, fileName); + } +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskResultAction.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskResultAction.java new file mode 100644 index 000000000..67fb6d6fe --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskResultAction.java @@ -0,0 +1,17 @@ +package com.bsd.pjcloud.common.asynctask.service; + +/** + * 携带成功结果的通用异步任务业务动作。 + *

用于导出等需要在成功终态返回结果文件的任务;动作在后台事务中执行,抛出异常会回滚并标记任务失败。

+ */ +@FunctionalInterface +public interface AsyncTaskResultAction { + + /** + * 执行具体异步业务并返回成功结果。 + * + * @param reporter 逐项进度报告器,业务完成一个计数项目后调用一次 + * @return 任务成功终态需要返回调用端的可选结果文件信息 + */ + AsyncTaskResult execute(AsyncTaskProgressReporter reporter) throws Exception; +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/BatchExportSupport.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/BatchExportSupport.java new file mode 100644 index 000000000..7ecb6bf3e --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/BatchExportSupport.java @@ -0,0 +1,99 @@ +package com.bsd.pjcloud.common.asynctask.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate; +import com.bsd.pjcloud.common.excel.stream.TemplateBatchWorkbook; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.core.io.ClassPathResource; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; + +/** 导出业务共用的分页、进度和完整文件响应适配,不改变业务字段转换。 */ +public final class BatchExportSupport { + /** 一个查询批次的最大明细数,与模板流式行窗口一致。 */ + public static final int BATCH_SIZE = 200; + /** XLSX 的附件响应类型。 */ + public static final String CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + + private BatchExportSupport() { } + + /** 按已有模板分页生成;仅在文件关闭后交给消费者上传或返回同步响应。 */ + public static R template(String template, Map values, + Function> fetcher, Function> converter, + AsyncTaskProgressReporter reporter, BatchExcelExportTemplate.FileConsumer consumer, + Consumer customizer) throws Exception { + Page first = fetcher.apply(1L); + long total = first.getTotal(); + if (total == 0) throw new AsyncTaskException("当前筛选条件没有可导出的数据"); + if (reporter != null) { + reporter.updateTotal(BatchExcelExportTemplate.calculateProgressTotal(total, BATCH_SIZE, 2), + "共 " + total + " 条,正在分批生成 Excel"); + } + try { + return TemplateBatchWorkbook.write(() -> new ClassPathResource(template).getInputStream(), total, BATCH_SIZE, + values, (current, size) -> current == 1 ? first.getRecords() : fetcher.apply(current).getRecords(), + converter, (completed, expected) -> { + if (reporter != null) reporter.advance("已写入 " + completed + " / " + expected + " 条"); + }, file -> { + if (reporter != null) reporter.advance("文件已生成,正在保存导出结果"); + return consumer.consume(file); + }, customizer); + } catch (IllegalArgumentException exception) { + // 模板行数校验的文案为受控提示,其他异常仍交给执行器统一处理。 + if (exception.getMessage() != null && exception.getMessage().startsWith("导出明细数超过")) { + throw new AsyncTaskException(exception.getMessage() + ",请缩小筛选范围"); + } + throw exception; + } catch (IllegalStateException exception) { + if (exception.getMessage() != null && exception.getMessage().startsWith("导出数据在执行期间发生变化")) { + throw new AsyncTaskException("导出期间数据发生变化,请重试"); + } + throw exception; + } + } + + /** 保持旧同步接口的附件语义;生成失败时尚未写出任何响应文件字节。 */ + public static Void respond(Path file, String fileName, HttpServletResponse response) throws Exception { + response.setContentType(CONTENT_TYPE); + response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + + URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20")); + Files.copy(file, response.getOutputStream()); + response.flushBuffer(); + return null; + } + + /** 延续旧模板的时间范围标题字段;缺失时间条件时占位符留空。 */ + public static Map timeValues(LocalDateTime[] range) { + Map values = new HashMap<>(); + if (range != null && range.length == 2 && range[0] != null && range[1] != null) { + DateTimeFormatter format = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + values.put("startTime", range[0].format(format)); + values.put("endTime", range[1].format(format)); + values.put("timeSpan", " 到 "); + } + return values; + } + + /** 钱包日期区间沿用零点时间标题,不改变实际日期筛选。 */ + public static Map timeValues(java.time.LocalDate[] range) { + if (range == null || range.length != 2) return new HashMap<>(); + return timeValues(new LocalDateTime[]{range[0] == null ? null : range[0].atStartOfDay(), range[1] == null ? null : range[1].atStartOfDay()}); + } + + /** 创建受导出批大小约束的分页,后续页不重复执行 COUNT。 */ + public static Page page(long current) { + Page page = new Page<>(current, BATCH_SIZE, current == 1); + page.setMaxLimit((long) BATCH_SIZE); + return page; + } +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskExecutorService.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskExecutorService.java new file mode 100644 index 000000000..8b9353c6e --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskExecutorService.java @@ -0,0 +1,217 @@ +package com.bsd.pjcloud.common.asynctask.service.impl; + +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskAction; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskException; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResultAction; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.common.security.util.NonWebTokenContextHolder; +import com.bsd.pjcloud.common.security.util.SecurityUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.task.TaskExecutor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 通用异步事务任务提交服务默认实现。 + *

实现会恢复提交线程的租户、安全和 Feign Token 上下文,并以单个 Spring 事务执行完整业务动作。

+ */ +@Slf4j +public class DefaultAsyncTaskExecutorService implements AsyncTaskExecutorService { + + /** 通用异步任务日志标签,用于统一检索任务提交和执行异常。 */ + private static final String LOG_TAG = "通用异步任务"; + + /** 后台事务失败时返回调用端的固定说明,避免暴露 SQL 或远程调用等内部异常细节。 */ + private static final String TASK_FAILED_MESSAGE = "导出任务执行失败,请重试"; + + /** 线程池拒绝任务时返回调用端的固定说明,提示稍后重新提交。 */ + private static final String TASK_REJECTED_MESSAGE = "任务提交失败,请稍后重试"; + + /** 公共异步任务工作线程池,用于限制后台任务并发并与业务专用线程池隔离。 */ + private final TaskExecutor asyncTaskWorkerExecutor; + + /** Redis 任务进度服务,用于初始化、更新和查询任务状态。 */ + private final AsyncTaskProgressService asyncTaskProgressService; + + /** Spring 编程式事务模板,保证整个异步业务动作成功或整体回滚。 */ + private final TransactionTemplate transactionTemplate; + + /** + * 创建通用异步任务执行器。 + * + * @param asyncTaskWorkerExecutor 公共异步任务专用工作线程池 + * @param asyncTaskProgressService Redis 任务进度服务 + * @param transactionTemplate Spring 编程式事务模板 + */ + public DefaultAsyncTaskExecutorService(TaskExecutor asyncTaskWorkerExecutor, + AsyncTaskProgressService asyncTaskProgressService, TransactionTemplate transactionTemplate) { + this.asyncTaskWorkerExecutor = asyncTaskWorkerExecutor; + this.asyncTaskProgressService = asyncTaskProgressService; + this.transactionTemplate = transactionTemplate; + } + + /** + * 创建并提交一个异步事务任务。 + * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskName 任务展示名称 + * @param total 需要处理的总项目数 + * @param action 后台事务中执行的业务动作 + * @return 等待执行的初始任务进度 + */ + @Override + public AsyncTaskProgressVO submit(String redisKey, String taskName, int total, AsyncTaskAction action) { + return submitWithResult(redisKey, taskName, total, reporter -> { + action.execute(reporter); + return AsyncTaskResult.empty(); + }); + } + + /** + * 创建并提交一个可在成功终态携带结果文件的异步事务任务。 + * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskName 任务展示名称 + * @param total 需要处理的总项目数 + * @param action 后台事务中执行并返回结果文件信息的业务动作 + * @return 等待执行的初始任务进度 + */ + @Override + public AsyncTaskProgressVO submitWithResult(String redisKey, String taskName, int total, AsyncTaskResultAction action) { + AsyncTaskProgressVO progress = asyncTaskProgressService.create(redisKey, taskName, total); + Long tenantId = TenantContextHolder.getTenantId(); + Authentication authentication = SecurityUtils.getAuthentication(); + String token = SecurityUtils.getToken(); + SecurityContextHolderStrategy strategy = SecurityContextHolder.getContextHolderStrategy(); + SecurityContext securityContext = strategy.createEmptyContext(); + securityContext.setAuthentication(authentication); + try { + asyncTaskWorkerExecutor.execute(() -> runTask(progress, tenantId, token, strategy, securityContext, action)); + } + catch (RuntimeException exception) { + log.error("|{}|任务提交线程池失败,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception); + markFailedSafely(progress, TASK_REJECTED_MESSAGE); + throw new AsyncTaskException(TASK_REJECTED_MESSAGE); + } + return progress; + } + + /** + * 在恢复租户、安全和 Feign Token 上下文后执行异步事务。 + * + * @param progress 初始任务进度 + * @param tenantId 请求提交时的租户 ID,恢复查询及任务键的租户隔离上下文 + * @param token 请求提交时的 OAuth2 Token,可为空 + * @param strategy 当前 Spring Security 上下文策略 + * @param securityContext 请求提交时复制出的安全上下文 + * @param action 后台事务中执行的业务动作 + */ + private void runTask(AsyncTaskProgressVO progress, Long tenantId, String token, SecurityContextHolderStrategy strategy, + SecurityContext securityContext, AsyncTaskResultAction action) { + try { + TenantContextHolder.setTenantId(tenantId); + strategy.setContext(securityContext); + NonWebTokenContextHolder.setToken(token); + asyncTaskProgressService.start(progress.getRedisKey(), progress.getTaskId()); + AtomicInteger completed = new AtomicInteger(); + AsyncTaskResult result; + try { + AsyncTaskProgressReporter reporter = createProgressReporter(progress, completed); + result = transactionTemplate.execute(status -> { + try { return action.execute(reporter); } + catch (RuntimeException exception) { throw exception; } + catch (Exception exception) { throw new IllegalStateException("导出执行异常", exception); } + }); + } + catch (Exception exception) { + log.error("|{}|任务事务执行失败,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception); + markFailedSafely(progress, exception instanceof AsyncTaskException ? exception.getMessage() : TASK_FAILED_MESSAGE); + return; + } + markSuccessSafely(progress, result); + } + catch (Exception exception) { + log.error("|{}|任务启动失败,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception); + markFailedSafely(progress, exception instanceof AsyncTaskException ? exception.getMessage() : TASK_FAILED_MESSAGE); + } + finally { + NonWebTokenContextHolder.clear(); + strategy.clearContext(); + TenantContextHolder.clear(); + } + } + + /** + * 创建同时支持逐项推进和动态总数更新的任务进度报告器。 + * + * @param progress 当前任务的业务命名空间和任务 ID + * @param completed 当前任务线程内的已处理计数器 + * @return 绑定当前任务 Redis 进度的报告器 + */ + private AsyncTaskProgressReporter createProgressReporter(AsyncTaskProgressVO progress, AtomicInteger completed) { + return new AsyncTaskProgressReporter() { + /** + * 将后台查询得到的真实总数写入 Redis,并同步约束线程内已处理计数。 + * + * @param total 真实总项目数,必须大于零 + * @param message 总数更新后的进度说明 + */ + @Override + public void updateTotal(int total, String message) { + completed.updateAndGet(current -> Math.min(current, total)); + asyncTaskProgressService.updateTotal(progress.getRedisKey(), progress.getTaskId(), total, message); + } + + /** + * 将已处理项目数递增一项并写入 Redis。 + * + * @param message 当前项目完成后的进度说明 + */ + @Override + public void advance(String message) { + int current = completed.incrementAndGet(); + asyncTaskProgressService.update(progress.getRedisKey(), progress.getTaskId(), current, message); + } + }; + } + + /** + * 尝试把任务写为失败状态;Redis 异常只记录日志,不能覆盖原始业务异常或阻断线程上下文清理。 + * + * @param progress 待更新的任务标识和业务命名空间 + * @param message 对调用端安全的失败说明 + */ + private void markFailedSafely(AsyncTaskProgressVO progress, String message) { + try { + asyncTaskProgressService.fail(progress.getRedisKey(), progress.getTaskId(), message); + } + catch (Exception exception) { + log.error("|{}|写入失败进度异常,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception); + } + } + + /** + * 尝试把已提交事务的任务写为成功状态;Redis 写入失败只记录,不能把已经提交的数据误报为已回滚。 + * + * @param progress 已完成数据库事务的任务标识和业务命名空间 + * @param result 业务动作返回的可选结果文件信息 + */ + private void markSuccessSafely(AsyncTaskProgressVO progress, AsyncTaskResult result) { + try { + asyncTaskProgressService.success(progress.getRedisKey(), progress.getTaskId(), "任务执行成功", result); + } + catch (Exception exception) { + log.error("|{}|写入成功进度异常,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception); + } + } +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskProgressService.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskProgressService.java new file mode 100644 index 000000000..e78d1d3a9 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskProgressService.java @@ -0,0 +1,262 @@ +package com.bsd.pjcloud.common.asynctask.service.impl; + +import com.bsd.pjcloud.common.asynctask.constant.AsyncTaskStatusEnum; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressService; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; +import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.common.security.util.SecurityUtils; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import org.springframework.data.redis.core.RedisTemplate; + +import java.time.Duration; +import java.util.UUID; +import java.util.regex.Pattern; + +/** + * 通用异步任务 Redis 进度服务默认实现。 + *

该实现显式拼接租户、业务命名空间、登录用户和任务 ID,兼容项目当前普通字符串 Redis Key 序列化器。

+ */ +public class DefaultAsyncTaskProgressService implements AsyncTaskProgressService { + + /** 所有通用异步任务 Redis Key 的固定业务命名空间。 */ + private static final String REDIS_KEY_PREFIX = "async:task:progress"; + + /** 任务状态的 Redis 保留时间,供页面刷新或短时重连后继续查询。 */ + private static final Duration TASK_TTL = Duration.ofHours(24); + + /** 业务 Redis Key 允许的字符和长度,防止调用方构造不可控的 Redis 路径。 */ + private static final Pattern REDIS_KEY_PATTERN = Pattern.compile("^[a-zA-Z0-9:_-]{1,80}$"); + + /** 不透明任务 ID 的固定格式,与本服务生成的 32 位 UUID 一致。 */ + private static final Pattern TASK_ID_PATTERN = Pattern.compile("^[a-fA-F0-9]{32}$"); + + /** Redis 操作模板,使用项目统一 JSON 序列化配置保存进度对象。 */ + private final RedisTemplate redisTemplate; + + /** + * 创建通用 Redis 进度服务。 + * + * @param redisTemplate 项目统一配置的 Redis 操作模板 + */ + public DefaultAsyncTaskProgressService(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + /** + * 创建等待执行的异步任务并写入 Redis。 + * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskName 任务展示名称 + * @param total 需要处理的总项目数,必须大于零 + * @return 已写入 Redis 的初始任务进度 + */ + @Override + public AsyncTaskProgressVO create(String redisKey, String taskName, int total) { + validateRedisKey(redisKey); + if (total <= 0) { + throw new IllegalArgumentException("异步任务总数必须大于0"); + } + AsyncTaskProgressVO progress = new AsyncTaskProgressVO(); + progress.setTaskId(UUID.randomUUID().toString().replace("-", "")); + progress.setRedisKey(redisKey); + progress.setTaskName(taskName); + progress.setStatus(AsyncTaskStatusEnum.PENDING.getStatus()); + progress.setTotal(total); + progress.setCompleted(0); + progress.setPercentage(0); + progress.setMessage(AsyncTaskStatusEnum.PENDING.getDescription()); + progress.setFinished(false); + progress.setSuccess(false); + save(progress); + return progress; + } + + /** + * 将任务标记为执行中。 + * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskId 不透明任务 ID + */ + @Override + public void start(String redisKey, String taskId) { + AsyncTaskProgressVO progress = requireProgress(redisKey, taskId); + progress.setStatus(AsyncTaskStatusEnum.RUNNING.getStatus()); + progress.setMessage(AsyncTaskStatusEnum.RUNNING.getDescription()); + save(progress); + } + + /** + * 在任务开始后更新真实总项目数并重新计算完成百分比。 + * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskId 不透明任务 ID + * @param total 后台查询得到的真实总项目数,必须大于零 + * @param message 总数更新后的进度说明 + */ + @Override + public void updateTotal(String redisKey, String taskId, int total, String message) { + if (total <= 0) { + throw new IllegalArgumentException("异步任务总数必须大于0"); + } + AsyncTaskProgressVO progress = requireProgress(redisKey, taskId); + int safeCompleted = Math.max(0, Math.min(progress.getCompleted(), total)); + progress.setStatus(AsyncTaskStatusEnum.RUNNING.getStatus()); + progress.setTotal(total); + progress.setCompleted(safeCompleted); + progress.setPercentage(calculatePercentage(safeCompleted, total)); + progress.setMessage(message == null || message.isBlank() ? "正在处理" : message); + save(progress); + } + + /** + * 更新任务执行进度。 + * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskId 不透明任务 ID + * @param completed 当前已处理项目数 + * @param message 当前进度说明 + */ + @Override + public void update(String redisKey, String taskId, int completed, String message) { + AsyncTaskProgressVO progress = requireProgress(redisKey, taskId); + int safeCompleted = Math.max(0, Math.min(completed, progress.getTotal())); + progress.setStatus(AsyncTaskStatusEnum.RUNNING.getStatus()); + progress.setCompleted(safeCompleted); + progress.setPercentage(calculatePercentage(safeCompleted, progress.getTotal())); + progress.setMessage(message == null || message.isBlank() ? "正在处理" : message); + save(progress); + } + + /** + * 将任务标记为成功并把进度置为 100%。 + * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskId 不透明任务 ID + * @param message 最终成功说明 + */ + @Override + public void success(String redisKey, String taskId, String message) { + success(redisKey, taskId, message, null); + } + + /** + * 将任务标记为成功、把进度置为 100%,并保存可选的结果文件信息。 + * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskId 不透明任务 ID + * @param message 最终成功说明 + * @param result 业务动作返回的可选结果文件信息 + */ + @Override + public void success(String redisKey, String taskId, String message, AsyncTaskResult result) { + AsyncTaskProgressVO progress = requireProgress(redisKey, taskId); + progress.setStatus(AsyncTaskStatusEnum.SUCCESS.getStatus()); + progress.setCompleted(progress.getTotal()); + progress.setPercentage(100); + progress.setMessage(message == null || message.isBlank() ? AsyncTaskStatusEnum.SUCCESS.getDescription() : message); + progress.setFinished(true); + progress.setSuccess(true); + if (result != null) { + progress.setResultFileUrl(result.fileUrl()); + progress.setResultFileName(result.fileName()); + } + save(progress); + } + + /** + * 将任务标记为失败并保留失败前执行进度。 + * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskId 不透明任务 ID + * @param message 对调用端安全的失败说明 + */ + @Override + public void fail(String redisKey, String taskId, String message) { + AsyncTaskProgressVO progress = requireProgress(redisKey, taskId); + progress.setStatus(AsyncTaskStatusEnum.FAILED.getStatus()); + progress.setMessage(message == null || message.isBlank() ? AsyncTaskStatusEnum.FAILED.getDescription() : message); + progress.setFinished(true); + progress.setSuccess(false); + save(progress); + } + + /** + * 查询当前用户创建的异步任务进度。 + * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskId 不透明任务 ID + * @return 任务进度;任务不存在、已过期或不属于当前用户时返回 {@code null} + */ + @Override + public AsyncTaskProgressVO get(String redisKey, String taskId) { + validateRedisKey(redisKey); + Object value = redisTemplate.opsForValue().get(buildRedisKey(redisKey, taskId)); + return value instanceof AsyncTaskProgressVO progress ? progress : null; + } + + /** + * 获取必须存在的任务进度。 + * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskId 不透明任务 ID + * @return 当前任务进度 + */ + private AsyncTaskProgressVO requireProgress(String redisKey, String taskId) { + AsyncTaskProgressVO progress = get(redisKey, taskId); + if (progress == null) { + throw new IllegalStateException("异步任务不存在或已过期"); + } + return progress; + } + + /** + * 保存任务进度并刷新统一过期时间。 + * + * @param progress 待保存的完整进度对象 + */ + private void save(AsyncTaskProgressVO progress) { + redisTemplate.opsForValue().set(buildRedisKey(progress.getRedisKey(), progress.getTaskId()), progress, TASK_TTL); + } + + /** + * 构建按当前用户隔离的业务 Redis Key。 + *

租户 ID 显式进入任务键,不依赖 Redis 序列化器隐式隔离。

+ * + * @param redisKey 业务指定的 Redis Key 命名空间 + * @param taskId 不透明任务 ID + * @return 仅服务端使用的业务 Redis Key,结构为固定前缀、租户 ID、业务命名空间、用户 ID 和任务 ID + */ + private String buildRedisKey(String redisKey, String taskId) { + PjcloudUser currentUser = SecurityUtils.getUser(); + if (currentUser == null || currentUser.getId() == null) { + throw new IllegalStateException("缺少异步任务用户上下文"); + } + if (taskId == null || !TASK_ID_PATTERN.matcher(taskId).matches()) { + throw new IllegalArgumentException("异步任务ID格式不正确"); + } + return String.join(":", REDIS_KEY_PREFIX, String.valueOf(TenantContextHolder.getTenantId()), redisKey, String.valueOf(currentUser.getId()), taskId); + } + + /** + * 校验业务 Redis Key 命名空间,限制长度与字符范围。 + * + * @param redisKey 业务指定的 Redis Key 命名空间 + */ + private void validateRedisKey(String redisKey) { + if (redisKey == null || !REDIS_KEY_PATTERN.matcher(redisKey).matches()) { + throw new IllegalArgumentException("异步任务Redis Key格式不正确"); + } + } + + /** + * 计算取整后的完成百分比。 + * + * @param completed 已完成项目数 + * @param total 总项目数 + * @return 0 至 99 的整数百分比;只有 success 终态可写入 100 + */ + private int calculatePercentage(int completed, int total) { + return total <= 0 ? 0 : (int) Math.min(99L, (long) completed * 100 / total); + } +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/vo/AsyncTaskProgressVO.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/vo/AsyncTaskProgressVO.java new file mode 100644 index 000000000..37c8aa6c8 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/vo/AsyncTaskProgressVO.java @@ -0,0 +1,66 @@ +package com.bsd.pjcloud.common.asynctask.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * 通用异步任务进度展示对象。 + *

对象存入 Redis 并通过通用进度接口返回,可用于导入、导出等后台任务。

+ */ +@Data +@Schema(description = "通用异步任务进度") +public class AsyncTaskProgressVO implements Serializable { + + /** 序列化版本号,用于 Redis 中任务状态的兼容反序列化。 */ + private static final long serialVersionUID = 1L; + + /** 调用端轮询使用的不透明任务 ID,不包含租户或用户信息。 */ + @Schema(description = "任务ID") + private String taskId; + + /** 业务指定的 Redis Key 命名空间,用于区分不同异步任务场景。 */ + @Schema(description = "Redis Key命名空间") + private String redisKey; + + /** 任务展示名称,例如后台用户导入。 */ + @Schema(description = "任务名称") + private String taskName; + + /** 固定任务状态编码:PENDING、RUNNING、SUCCESS、FAILED。 */ + @Schema(description = "任务状态") + private String status; + + /** 任务需要处理的总项目数。 */ + @Schema(description = "总项目数") + private Integer total; + + /** 当前执行到的项目数;任务整体失败时仅表示回滚或失败前的执行位置,不代表最终成功写入数量。 */ + @Schema(description = "已处理项目数") + private Integer completed; + + /** 任务完成百分比,取值范围为 0 至 100。 */ + @Schema(description = "完成百分比") + private Integer percentage; + + /** 当前进度或最终结果说明。 */ + @Schema(description = "任务消息") + private String message; + + /** 是否已进入成功或失败终态,供调用端决定是否停止轮询。 */ + @Schema(description = "是否完成") + private Boolean finished; + + /** 终态是否成功;任务未结束时固定为 {@code false}。 */ + @Schema(description = "是否成功") + private Boolean success; + + /** 任务成功后生成文件的下载地址;导入等无附件任务为空。 */ + @Schema(description = "任务结果文件下载地址") + private String resultFileUrl; + + /** 任务成功后生成文件的下载名称;导入等无附件任务为空。 */ + @Schema(description = "任务结果文件名称") + private String resultFileName; +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/pjcloud-common/pjcloud-common-async-task/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..16dfa6472 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.bsd.pjcloud.common.asynctask.config.AsyncTaskAutoConfiguration +com.bsd.pjcloud.common.asynctask.config.AsyncTaskWebAutoConfiguration diff --git a/pjcloud-common/pjcloud-common-bom/pom.xml b/pjcloud-common/pjcloud-common-bom/pom.xml index aa59cd924..b4ee9fdd5 100644 --- a/pjcloud-common/pjcloud-common-bom/pom.xml +++ b/pjcloud-common/pjcloud-common-bom/pom.xml @@ -62,6 +62,11 @@ + + com.bsd + pjcloud-common-async-task + ${pjcloud.version} + com.bsd pjcloud-common-core diff --git a/pjcloud-common/pjcloud-common-excel/README.md b/pjcloud-common/pjcloud-common-excel/README.md new file mode 100644 index 000000000..d05b7cd73 --- /dev/null +++ b/pjcloud-common/pjcloud-common-excel/README.md @@ -0,0 +1,56 @@ +# 导出默认样式 token + +`ExcelStyleTokens` 提供全局默认样式,配置前缀为 `excel.export-style`,可放在服务配置或 Nacos 公共配置中。未配置的值使用代码默认值,表头与正文可以分别设置字体、字号、加粗、文字颜色、背景颜色、行高、对齐、换行和缩进。 + +当前仅 `/admin/user/export-async` 接入。原同步用户导出和其他业务导出的样式不受影响,已有模板无需修改。 + +```yaml +excel: + export-style: + header: + font-name: Microsoft YaHei + font-size: 11 + bold: true + font-color: "#FFFFFF" + background-color: "#1F4E78" + row-height: 36 + alignment: CENTER + wrap-text: true + indent: 0 + body: + font-name: Microsoft YaHei + font-size: 11 + bold: false + font-color: "#243247" + background-color: "#FFFFFF" + row-height: 26 + alignment: LEFT + wrap-text: false + indent: 1 + striped: true + alternate-row-color: "#F3F6FA" + border-style: THIN + border-color: "#DCE3EC" + column-width: 22 + # 可选:按 Java 字段名覆盖业务默认列宽。[] 保留字段名大小写。 + column-widths: + "[userId]": 24 + "[username]": 20 + "[deptName]": 28 + "[roleNameList]": 30 + freeze-header: true + auto-filter: true + show-grid-lines: false +``` + +颜色支持带或不带 `#` 的六位 RGB;YAML 中带 `#` 的颜色必须加引号。字号与行高单位为磅,列宽单位为 Excel 字符宽度(1~255)。配置绑定时校验颜色及尺寸。字体由打开 Excel 的电脑解析,可改为该电脑已安装的中文字体。边框样式与颜色应用于表头列分隔和正文水平分隔,正文不绘制完整网格。 + +列宽优先级:配置中的 `column-widths` > 业务默认列宽 > `column-width`。用户导出已按编号、手机、邮箱、部门、角色和日期等字段分别设置列宽,不扫描全部明细来计算列宽。长文本保持原值,默认正文不换行;需要多行显示时可同时增加 `body.row-height` 并开启 `body.wrap-text`。开启交替行底纹时,偶数数据行使用 `alternate-row-color`,其余数据行使用 `body.background-color`。 + +后续导出可显式注册处理器来复用样式(本次不接入其他接口): + +```java +builder.registerWriteHandler(new ExcelExportStyleHandler(excelConfig.getExportStyle())); +``` + +每次导出创建独立处理器,在提交任务时固定当前 token。处理器只支持 XLSX,缓存工作簿中的字体与单元格样式,条纹按实际行号计算,适用于分批流式写入。现有转换器、单元格类型、数据格式及导出列保持原样。固定格式模板应继续使用自身样式,不自动注册此处理器。 diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/config/ExcelConfigProperties.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/config/ExcelConfigProperties.java index d32d000d4..834607ae3 100644 --- a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/config/ExcelConfigProperties.java +++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/config/ExcelConfigProperties.java @@ -1,13 +1,19 @@ package com.bsd.pjcloud.common.excel.config; +import com.bsd.pjcloud.common.excel.style.ExcelStyleTokens; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; +import org.springframework.validation.annotation.Validated; /** * @author pjcloud * @date 2020/3/29 */ @Data +@Validated @ConfigurationProperties(prefix = ExcelConfigProperties.PREFIX) public class ExcelConfigProperties { @@ -18,4 +24,12 @@ public class ExcelConfigProperties { */ private String templatePath = "excel"; + /** + * 全局导出样式 token。只有显式注册 ExcelExportStyleHandler 的导出才会应用。 + */ + @Valid + @NotNull + @NestedConfigurationProperty + private ExcelStyleTokens exportStyle = new ExcelStyleTokens(); + } diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/BatchExcelExportTemplate.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/BatchExcelExportTemplate.java new file mode 100644 index 000000000..1240086fc --- /dev/null +++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/BatchExcelExportTemplate.java @@ -0,0 +1,617 @@ +package com.bsd.pjcloud.common.excel.stream; + +import org.apache.poi.ss.SpreadsheetVersion; +import org.apache.poi.xssf.streaming.SXSSFSheet; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; + +/** + * 基于 XLSX 模板的通用分批流式导出模板。 + * + *

该模板统一管理分页循环、预统计数量校验、SXSSF 临时文件和最终 XLSX 临时文件,业务方只需提供 + * 模板初始化、分页查询、逐行写入、尾部写入、进度通知和文件消费回调。该类不依赖异步任务或文件中心, + * 可由不同业务按需组合。

+ */ +public final class BatchExcelExportTemplate { + + /** Excel 2007 单个工作表支持的最大行数。 */ + private static final int XLSX_MAX_ROW_COUNT = SpreadsheetVersion.EXCEL2007.getMaxRows(); + + /** 默认分页大小和 SXSSF 内存行窗口大小。 */ + private static final int DEFAULT_BATCH_SIZE = 1000; + + /** 默认临时文件名前缀,必须满足 JDK 至少三个字符的约束。 */ + private static final String DEFAULT_TEMPORARY_FILE_PREFIX = "batch-excel-export-"; + + /** 公共导出模板日志记录器,仅记录临时文件清理失败等基础设施异常。 */ + private static final Logger LOGGER = LoggerFactory.getLogger(BatchExcelExportTemplate.class); + + /** + * 工具类不允许实例化。 + */ + private BatchExcelExportTemplate() { + } + + /** + * 创建一次分批导出的链式配置器。 + * + * @param expectedTotal 导出前按相同筛选条件统计的明细总数,必须大于零 + * @param 分页查询返回的业务行类型 + * @param 模板初始化后供逐行及尾部写入共享的上下文类型 + * @param 最终文件消费回调的返回类型 + * @return 尚未执行的导出配置器 + */ + public static Builder builder(long expectedTotal) { + return new Builder<>(expectedTotal); + } + + /** + * 根据总记录数和分页大小计算实际查询批次数。 + * + * @param expectedTotal 导出前预统计的明细总数,必须大于零 + * @param batchSize 单次分页查询大小,必须大于零 + * @return 向上取整后的批次数 + */ + public static int calculateBatchCount(long expectedTotal, int batchSize) { + validatePositive(expectedTotal, "导出总数必须大于零"); + validatePositive(batchSize, "导出批大小必须大于零"); + long batchCount = (expectedTotal - 1) / batchSize + 1; + if (batchCount > Integer.MAX_VALUE) { + throw new IllegalArgumentException("导出批次数超过整数上限"); + } + return (int) batchCount; + } + + /** + * 计算当前 XLSX 工作表在指定布局下最多可写入的明细行数。 + * + * @param dataStartRowIndex 明细开始写入的 Excel 零基行号 + * @param reservedTailRowCount 明细之后为合计、备注等内容预留的行数 + * @return 不超过 Excel 2007 单工作表上限的最大明细行数 + */ + public static int calculateMaxDetailRowCount(int dataStartRowIndex, int reservedTailRowCount) { + if (dataStartRowIndex < 0) { + throw new IllegalArgumentException("导出明细起始行不得小于零"); + } + if (reservedTailRowCount < 0) { + throw new IllegalArgumentException("导出尾部预留行数不得小于零"); + } + long maxDetailRowCount = (long) XLSX_MAX_ROW_COUNT - dataStartRowIndex - reservedTailRowCount; + if (maxDetailRowCount < 0) { + throw new IllegalArgumentException("导出固定行和尾部预留行超过单个工作表上限"); + } + return (int) maxDetailRowCount; + } + + /** + * 计算异步任务可使用的总进度步数。 + * + *

每个查询批次占一步,文件上传、异步任务成功落库等非批次动作由业务方通过额外步数声明。

+ * + * @param expectedTotal 导出前预统计的明细总数,必须大于零 + * @param batchSize 单次分页查询大小,必须大于零 + * @param extraSteps 文件上传等额外步骤数量,不得小于零 + * @return 批次数与额外步骤之和 + */ + public static int calculateProgressTotal(long expectedTotal, int batchSize, int extraSteps) { + if (extraSteps < 0) { + throw new IllegalArgumentException("导出额外进度步数不得小于零"); + } + long progressTotal = (long) calculateBatchCount(expectedTotal, batchSize) + extraSteps; + if (progressTotal > Integer.MAX_VALUE) { + throw new IllegalArgumentException("导出进度总步数超过整数上限"); + } + return (int) progressTotal; + } + + /** + * 校验正整数参数。 + * + * @param value 待校验数值 + * @param message 参数非法时使用的异常信息 + */ + private static void validatePositive(long value, String message) { + if (value <= 0) { + throw new IllegalArgumentException(message); + } + } + + /** + * 打开模板输入流的回调,返回的流由公共导出模板负责关闭。 + */ + @FunctionalInterface + public interface TemplateSupplier { + + /** + * 打开本次导出使用的 XLSX 模板。 + * + * @return 非空的模板输入流 + * @throws Exception 模板不存在或无法读取时抛出 + */ + InputStream open() throws Exception; + } + + /** + * 模板初始化回调,用于读取样式、替换标题和移除占位行。 + * + * @param 后续逐行和尾部写入共享的上下文类型 + */ + @FunctionalInterface + public interface TemplateInitializer { + + /** + * 在 XSSFWorkbook 转换为 SXSSFWorkbook 前初始化模板。 + * + * @param workbook 可编辑的模板工作簿,生命周期由公共导出模板管理 + * @return 后续写入共享的业务上下文,允许为空 + * @throws Exception 模板结构不符合业务约束时抛出 + */ + C initialize(XSSFWorkbook workbook) throws Exception; + } + + /** + * 分页数据查询回调,业务查询必须使用稳定且唯一的排序规则。 + * + * @param 业务行类型 + */ + @FunctionalInterface + public interface PageFetcher { + + /** + * 查询指定页的数据。 + * + * @param pageNumber 从一开始的页码 + * @param batchSize 单页最大记录数 + * @return 当前页记录;无记录时可返回空列表或空值 + * @throws Exception 数据查询失败时抛出 + */ + List fetch(long pageNumber, int batchSize) throws Exception; + } + + /** + * 单行写入回调,业务可在此补充序号、格式化字段和累计跨批次汇总。 + * + * @param 业务行类型 + * @param 模板共享上下文类型 + */ + @FunctionalInterface + public interface RowWriter { + + /** + * 将一条业务记录写入指定工作表行。 + * + * @param sheet SXSSF 流式工作表 + * @param rowIndex Excel 零基行号 + * @param dataIndex 当前记录在全部明细中的零基序号 + * @param item 当前业务记录 + * @param context 模板初始化产生的共享上下文,允许为空 + * @throws Exception 数据转换或行写入失败时抛出 + */ + void write(SXSSFSheet sheet, int rowIndex, long dataIndex, T item, C context) throws Exception; + } + + /** + * 明细写完后的尾部写入回调,可用于合计、签章或备注行。 + * + * @param 模板共享上下文类型 + */ + @FunctionalInterface + public interface FooterWriter { + + /** + * 从预留尾部区域的第一行开始写入业务内容。 + * + * @param sheet SXSSF 流式工作表 + * @param firstFooterRowIndex 尾部区域第一行的 Excel 零基行号 + * @param processedTotal 已成功写入的明细总数 + * @param context 模板初始化产生的共享上下文,允许为空 + * @throws Exception 尾部内容写入失败时抛出 + */ + void write(SXSSFSheet sheet, int firstFooterRowIndex, long processedTotal, C context) throws Exception; + } + + /** + * 每批数据完整写入后的进度通知回调。 + */ + @FunctionalInterface + public interface ProgressListener { + + /** + * 报告已完成写入的明细数量。 + * + * @param processedTotal 已完成写入的明细总数 + * @param expectedTotal 导出前预统计的明细总数 + * @throws Exception 进度上报失败且需要终止导出时抛出 + */ + void onBatchCompleted(long processedTotal, long expectedTotal) throws Exception; + } + + /** + * 完整 XLSX 临时文件的消费回调,可用于上传文件中心或移动到业务目录。 + * + * @param 文件消费结果类型 + */ + @FunctionalInterface + public interface FileConsumer { + + /** + * 在临时文件仍然存在时消费完整 XLSX 文件。 + * + * @param file 临时 XLSX 文件,只在回调执行期间有效,不得异步持有 + * @return 文件上传地址等业务结果 + * @throws Exception 文件读取、上传或移动失败时抛出 + */ + R consume(Path file) throws Exception; + } + + /** + * 单次分批流式导出的链式配置器。 + * + *

配置器是有状态且非线程安全的,应在每次导出时新建并仅由当前线程使用。

+ * + * @param 分页查询返回的业务行类型 + * @param 模板共享上下文类型 + * @param 完整文件消费结果类型 + */ + public static final class Builder { + + /** 导出前按相同筛选条件统计的明细总数。 */ + private final long expectedTotal; + + /** 单次分页查询大小,同时作为 SXSSF 内存行窗口大小。 */ + private int batchSize = DEFAULT_BATCH_SIZE; + + /** 明细开始写入的 Excel 零基行号。 */ + private int dataStartRowIndex; + + /** 明细之后为合计、备注等内容预留的行数。 */ + private int reservedTailRowCount; + + /** 目标工作表的零基下标。 */ + private int sheetIndex; + + /** 最终 XLSX 临时文件的文件名前缀。 */ + private String temporaryFilePrefix = DEFAULT_TEMPORARY_FILE_PREFIX; + + /** 是否压缩 SXSSF 生成的工作表临时 XML 文件,以磁盘计算换取较低临时空间占用。 */ + private boolean compressTemporaryFiles = true; + + /** 是否使用共享字符串表;默认关闭以避免大量唯一文本占用堆内存。 */ + private boolean useSharedStringsTable; + + /** 业务提供的模板输入流工厂。 */ + private TemplateSupplier templateSupplier; + + /** 业务提供的模板初始化回调。 */ + private TemplateInitializer templateInitializer; + + /** 业务提供的分页查询回调。 */ + private PageFetcher pageFetcher; + + /** 业务提供的逐行写入回调。 */ + private RowWriter rowWriter; + + /** 可选的业务尾部写入回调。 */ + private FooterWriter footerWriter; + + /** 可选的批次进度通知回调。 */ + private ProgressListener progressListener; + + /** 业务提供的完整文件消费回调。 */ + private FileConsumer fileConsumer; + + /** + * 创建单次导出配置器。 + * + * @param expectedTotal 导出前预统计的明细总数 + */ + private Builder(long expectedTotal) { + this.expectedTotal = expectedTotal; + } + + /** + * 设置分页大小和 SXSSF 内存行窗口大小。 + * + * @param batchSize 正整数批大小 + * @return 当前配置器 + */ + public Builder batchSize(int batchSize) { + this.batchSize = batchSize; + return this; + } + + /** + * 设置明细开始写入的 Excel 零基行号。 + * + * @param dataStartRowIndex 非负的零基行号 + * @return 当前配置器 + */ + public Builder dataStartRowIndex(int dataStartRowIndex) { + this.dataStartRowIndex = dataStartRowIndex; + return this; + } + + /** + * 设置明细之后必须预留的尾部行数。 + * + * @param reservedTailRowCount 合计、备注等尾部内容占用的非负行数 + * @return 当前配置器 + */ + public Builder reservedTailRowCount(int reservedTailRowCount) { + this.reservedTailRowCount = reservedTailRowCount; + return this; + } + + /** + * 设置目标工作表下标。 + * + * @param sheetIndex 非负的工作表零基下标 + * @return 当前配置器 + */ + public Builder sheetIndex(int sheetIndex) { + this.sheetIndex = sheetIndex; + return this; + } + + /** + * 设置最终 XLSX 临时文件的文件名前缀。 + * + * @param temporaryFilePrefix 至少三个字符的文件名前缀 + * @return 当前配置器 + */ + public Builder temporaryFilePrefix(String temporaryFilePrefix) { + this.temporaryFilePrefix = temporaryFilePrefix; + return this; + } + + /** + * 设置是否压缩 SXSSF 的临时 XML 文件。 + * + * @param compressTemporaryFiles 是否启用临时 XML 压缩 + * @return 当前配置器 + */ + public Builder compressTemporaryFiles(boolean compressTemporaryFiles) { + this.compressTemporaryFiles = compressTemporaryFiles; + return this; + } + + /** + * 设置是否启用共享字符串表。 + * + * @param useSharedStringsTable 是否把字符串集中写入共享表 + * @return 当前配置器 + */ + public Builder useSharedStringsTable(boolean useSharedStringsTable) { + this.useSharedStringsTable = useSharedStringsTable; + return this; + } + + /** + * 设置模板输入流工厂。 + * + * @param templateSupplier 每次执行时打开一个新模板流的回调 + * @return 当前配置器 + */ + public Builder templateSupplier(TemplateSupplier templateSupplier) { + this.templateSupplier = templateSupplier; + return this; + } + + /** + * 设置模板初始化回调。 + * + * @param templateInitializer 模板样式读取和占位内容清理回调 + * @return 当前配置器 + */ + public Builder templateInitializer(TemplateInitializer templateInitializer) { + this.templateInitializer = templateInitializer; + return this; + } + + /** + * 设置分页查询回调。 + * + * @param pageFetcher 按页码和批大小查询业务数据的回调 + * @return 当前配置器 + */ + public Builder pageFetcher(PageFetcher pageFetcher) { + this.pageFetcher = pageFetcher; + return this; + } + + /** + * 设置逐行写入回调。 + * + * @param rowWriter 将单条业务数据写入工作表的回调 + * @return 当前配置器 + */ + public Builder rowWriter(RowWriter rowWriter) { + this.rowWriter = rowWriter; + return this; + } + + /** + * 设置可选的尾部写入回调。 + * + * @param footerWriter 明细写完后写入合计、备注等内容的回调 + * @return 当前配置器 + */ + public Builder footerWriter(FooterWriter footerWriter) { + this.footerWriter = footerWriter; + return this; + } + + /** + * 设置可选的批次进度通知回调。 + * + * @param progressListener 每批数据完整写入后的通知回调 + * @return 当前配置器 + */ + public Builder progressListener(ProgressListener progressListener) { + this.progressListener = progressListener; + return this; + } + + /** + * 设置最终 XLSX 文件消费回调。 + * + * @param fileConsumer 上传、移动或读取最终临时文件的回调 + * @return 当前配置器 + */ + public Builder fileConsumer(FileConsumer fileConsumer) { + this.fileConsumer = fileConsumer; + return this; + } + + /** + * 执行分批查询、流式写入和最终文件消费。 + * + * @return 文件消费回调返回的业务结果 + * @throws Exception 模板读取、查询、写入、进度上报或文件消费失败时抛出 + */ + public R execute() throws Exception { + validate(); + Path outputPath = null; + try { + outputPath = Files.createTempFile(temporaryFilePrefix, ".xlsx"); + writeWorkbook(outputPath); + return fileConsumer.consume(outputPath); + } + finally { + deleteTemporaryFile(outputPath); + } + } + + /** + * 校验所有基础参数和必需回调,避免进入写入流程后才暴露配置错误。 + */ + private void validate() { + validatePositive(expectedTotal, "导出总数必须大于零"); + validatePositive(batchSize, "导出批大小必须大于零"); + if (dataStartRowIndex < 0) { + throw new IllegalArgumentException("导出明细起始行不得小于零"); + } + if (reservedTailRowCount < 0) { + throw new IllegalArgumentException("导出尾部预留行数不得小于零"); + } + if (sheetIndex < 0) { + throw new IllegalArgumentException("导出工作表下标不得小于零"); + } + if (temporaryFilePrefix == null || temporaryFilePrefix.length() < 3) { + throw new IllegalArgumentException("导出临时文件名前缀不得少于三个字符"); + } + int maxDetailRowCount = calculateMaxDetailRowCount(dataStartRowIndex, reservedTailRowCount); + if (expectedTotal > maxDetailRowCount) { + throw new IllegalArgumentException("导出明细数超过当前工作表可容纳的 " + maxDetailRowCount + " 条"); + } + Objects.requireNonNull(templateSupplier, "导出模板输入流工厂不能为空"); + Objects.requireNonNull(templateInitializer, "导出模板初始化回调不能为空"); + Objects.requireNonNull(pageFetcher, "导出分页查询回调不能为空"); + Objects.requireNonNull(rowWriter, "导出逐行写入回调不能为空"); + Objects.requireNonNull(fileConsumer, "导出文件消费回调不能为空"); + } + + /** + * 创建并写出 SXSSF 工作簿,同时保证其工作表临时 XML 被释放。 + * + * @param outputPath 最终 XLSX 临时文件路径 + * @throws Exception 模板、查询或写入任一环节失败时抛出 + */ + private void writeWorkbook(Path outputPath) throws Exception { + try (InputStream template = Objects.requireNonNull(templateSupplier.open(), "导出模板输入流不能为空"); + XSSFWorkbook templateWorkbook = new XSSFWorkbook(template)) { + C context = templateInitializer.initialize(templateWorkbook); + SXSSFWorkbook workbook = new SXSSFWorkbook(templateWorkbook, batchSize, compressTemporaryFiles, + useSharedStringsTable); + workbook.setShouldCalculateSheetDimensions(true); + try (workbook; OutputStream output = Files.newOutputStream(outputPath)) { + writeRows(workbook.getSheetAt(sheetIndex), context); + workbook.write(output); + } + finally { + // close 关闭压缩包句柄,dispose 负责删除 SXSSF 为各工作表创建的临时 XML。 + workbook.dispose(); + } + } + } + + /** + * 按页读取并逐行写入数据,最后校验实际数量与预统计数量一致。 + * + * @param sheet 目标 SXSSF 工作表 + * @param context 模板初始化产生的共享上下文 + * @throws Exception 查询、行写入或尾部写入失败时抛出 + */ + private void writeRows(SXSSFSheet sheet, C context) throws Exception { + long processedTotal = 0; + long pageNumber = 1; + while (true) { + List rows = pageFetcher.fetch(pageNumber++, batchSize); + if (rows == null || rows.isEmpty()) { + break; + } + if (rows.size() > batchSize) { + throw new IllegalStateException("导出分页查询返回数量超过配置的批大小"); + } + if (processedTotal + rows.size() > expectedTotal) { + throw dataChangedException(processedTotal + rows.size()); + } + for (T item : rows) { + int rowIndex = Math.toIntExact((long) dataStartRowIndex + processedTotal); + rowWriter.write(sheet, rowIndex, processedTotal, item, context); + processedTotal++; + } + if (progressListener != null) { + progressListener.onBatchCompleted(processedTotal, expectedTotal); + } + if (rows.size() < batchSize && processedTotal < expectedTotal) { + break; + } + } + if (processedTotal != expectedTotal) { + throw dataChangedException(processedTotal); + } + if (footerWriter != null) { + int footerRowIndex = Math.toIntExact((long) dataStartRowIndex + processedTotal); + footerWriter.write(sheet, footerRowIndex, processedTotal, context); + } + } + + /** + * 创建导出期间数据变化异常,统一向业务方暴露预期数量和实际数量。 + * + * @param actualTotal 当前已读取或即将写入的实际数量 + * @return 可直接抛出的状态异常 + */ + private IllegalStateException dataChangedException(long actualTotal) { + return new IllegalStateException("导出数据在执行期间发生变化,预期 " + expectedTotal + " 条,实际 " + + actualTotal + " 条"); + } + + /** + * 删除公共模板创建的最终 XLSX 临时文件,清理失败仅记录告警,避免覆盖原始业务结果。 + * + * @param outputPath 待删除的临时文件路径,允许为空 + */ + private void deleteTemporaryFile(Path outputPath) { + if (outputPath == null) { + return; + } + try { + Files.deleteIfExists(outputPath); + } + catch (Exception exception) { + LOGGER.warn("|批量Excel导出|清理最终临时文件失败,path:{}", outputPath, exception); + } + } + } +} diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/ExportMultipartFile.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/ExportMultipartFile.java new file mode 100644 index 000000000..1656d02ef --- /dev/null +++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/ExportMultipartFile.java @@ -0,0 +1,24 @@ +package com.bsd.pjcloud.common.excel.stream; + +import org.springframework.web.multipart.MultipartFile; +import java.io.*; +import java.nio.file.*; + +/** 文件中心上传适配;本地上传直接读取临时文件流,不使用测试库的 MockMultipartFile。 */ +public final class ExportMultipartFile implements MultipartFile { + /** 完整且已关闭的导出文件。 */ + private final Path path; + /** 文件中心保存的业务文件名。 */ + private final String fileName; + + /** 绑定本次导出文件;调用方负责在上传完成后清理文件。 */ + public ExportMultipartFile(Path path, String fileName) { this.path = path; this.fileName = fileName; } + @Override public String getName() { return "file"; } + @Override public String getOriginalFilename() { return fileName; } + @Override public String getContentType() { return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; } + @Override public boolean isEmpty() { return getSize() == 0; } + @Override public long getSize() { try { return Files.size(path); } catch (IOException exception) { throw new UncheckedIOException(exception); } } + @Override public byte[] getBytes() throws IOException { return Files.readAllBytes(path); } + @Override public InputStream getInputStream() throws IOException { return Files.newInputStream(path); } + @Override public void transferTo(File destination) throws IOException { Files.copy(path, destination.toPath(), StandardCopyOption.REPLACE_EXISTING); } +} diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/TemplateBatchWorkbook.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/TemplateBatchWorkbook.java new file mode 100644 index 000000000..a1e939ed5 --- /dev/null +++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/TemplateBatchWorkbook.java @@ -0,0 +1,182 @@ +package com.bsd.pjcloud.common.excel.stream; + +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; + +import java.io.InputStream; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.*; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** 保留既有占位符模板布局的分批工作簿;仅缓存模板行及一个数据批次。 */ +public final class TemplateBatchWorkbook { + /** 同时识别列表占位符与标题、合计占位符。 */ + private static final Pattern TOKEN = Pattern.compile("\\{(\\.?[^{}]+)}"); + + private TemplateBatchWorkbook() { } + + /** + * 按原模板流式生成完整文件,文件消费者返回后自动清理临时文件。 + * values 可由逐行转换器累计合计;只有写入尾部时才读取最终合计。 + */ + public static R write(BatchExcelExportTemplate.TemplateSupplier template, long total, int batchSize, + Map values, BatchExcelExportTemplate.PageFetcher fetcher, + Function> converter, + BatchExcelExportTemplate.ProgressListener progress, + BatchExcelExportTemplate.FileConsumer consumer, + Consumer customizer) throws Exception { + int start; + int tail; + // 先读取很小的模板确定物理行数上限,不能把大工作簿加载进 XSSF。 + try (InputStream input = template.open(); XSSFWorkbook workbook = new XSSFWorkbook(input)) { + customizer.accept(workbook); + start = findDetailRow(workbook.getSheetAt(0)); + tail = workbook.getSheetAt(0).getLastRowNum() - start; + } + return BatchExcelExportTemplate.builder(total) + .batchSize(batchSize).dataStartRowIndex(start).reservedTailRowCount(tail) + .templateSupplier(template) + .templateInitializer(workbook -> { + customizer.accept(workbook); + return prepare(workbook, values); + }) + .pageFetcher(fetcher) + .rowWriter((sheet, row, index, item, layout) -> { + Map data = converter.apply(item); + data.putIfAbsent("lineNumber", index + 1); + copyRow(sheet.createRow(row), layout.detail, data, layout); + }) + .footerWriter((sheet, row, count, layout) -> { + for (int i = 0; i < layout.footer.size(); i++) { + copyRow(sheet.createRow(row + i), layout.footer.get(i), values, layout); + } + for (CellRangeAddress merge : layout.tailMerges) { + int delta = row - layout.start - 1; + sheet.addMergedRegion(new CellRangeAddress(merge.getFirstRow() + delta, + merge.getLastRow() + delta, merge.getFirstColumn(), merge.getLastColumn())); + } + }).progressListener(progress).fileConsumer(consumer).execute(); + } + + /** 从列表占位符定位明细行,拒绝误用无明细模板。 */ + private static int findDetailRow(Sheet sheet) { + for (Row row : sheet) { + for (Cell cell : row) { + if (cell.getCellType() == CellType.STRING && cell.getStringCellValue().contains("{.")) { + return row.getRowNum(); + } + } + } + throw new IllegalArgumentException("导出模板缺少明细占位行"); + } + + /** 缓存原始样式和合计布局,移除占位区域后交给 SXSSF 顺序写入。 */ + private static Layout prepare(XSSFWorkbook workbook, Map values) { + Sheet sheet = workbook.getSheetAt(0); + int start = findDetailRow(sheet); + Layout layout = new Layout(start, snapshot(sheet.getRow(start)), new ArrayList<>(), new ArrayList<>(), new HashMap<>()); + for (int i = start + 1; i <= sheet.getLastRowNum(); i++) layout.footer.add(snapshot(sheet.getRow(i))); + for (int i = sheet.getNumMergedRegions() - 1; i >= 0; i--) { + CellRangeAddress merge = sheet.getMergedRegion(i); + if (merge.getFirstRow() > start) { + layout.tailMerges.add(merge); + sheet.removeMergedRegion(i); + } else if (merge.getLastRow() >= start) { + throw new IllegalArgumentException("导出模板明细行不支持跨行合并"); + } + } + for (int i = sheet.getLastRowNum(); i >= start; i--) { + Row row = sheet.getRow(i); + if (row != null) sheet.removeRow(row); + } + for (Row row : sheet) { + for (Cell cell : row) { + if (cell.getCellType() == CellType.STRING) { + putValue(cell, resolve(cell.getStringCellValue(), values), layout); + } + } + } + return layout; + } + + /** 只保存模板的单元格值、样式及行高,不复制任何业务数据。 */ + private static RowPattern snapshot(Row row) { + List cells = new ArrayList<>(); + if (row != null) { + for (Cell cell : row) { + Object value = switch (cell.getCellType()) { + case STRING -> cell.getStringCellValue(); + case NUMERIC -> cell.getNumericCellValue(); + case BOOLEAN -> cell.getBooleanCellValue(); + case FORMULA -> cell.getCellFormula(); + default -> null; + }; + cells.add(new CellPattern(cell.getColumnIndex(), cell.getCellStyle(), cell.getCellType(), value)); + } + } + return new RowPattern(row == null ? (short) -1 : row.getHeight(), cells); + } + + /** 逐行复用模板样式,避免创建与数据量成正比的 CellStyle。 */ + private static void copyRow(Row row, RowPattern pattern, Map values, Layout layout) { + row.setHeight(pattern.height); + for (CellPattern source : pattern.cells) { + Cell cell = row.createCell(source.column); + cell.setCellStyle(source.style); + if (source.type == CellType.FORMULA) cell.setCellFormula((String) source.value); + else putValue(cell, source.value instanceof String text ? resolve(text, values) : source.value, layout); + } + } + + /** 完整占位符保留数值类型;混合文本占位符沿用字符串结果。 */ + private static Object resolve(String text, Map values) { + Matcher matcher = TOKEN.matcher(text); + if (matcher.matches()) return values.get(matcher.group(1).replaceFirst("^\\.", "")); + matcher.reset(); + StringBuilder result = new StringBuilder(); + while (matcher.find()) { + Object value = values.get(matcher.group(1).replaceFirst("^\\.", "")); + matcher.appendReplacement(result, Matcher.quoteReplacement(value == null ? "" : value.toString())); + } + return matcher.appendTail(result).toString(); + } + + /** 按 EasyExcel 模板填充的默认类型写入,日期使用相同的日期格式。 */ + private static void putValue(Cell cell, Object value, Layout layout) { + if (value == null) cell.setBlank(); + else if (value instanceof Number number) cell.setCellValue(number.doubleValue()); + else if (value instanceof Boolean bool) cell.setCellValue(bool); + else if (value instanceof LocalDateTime date) { + cell.setCellValue(date); + applyDateStyle(cell, layout); + } else if (value instanceof LocalDate date) { + cell.setCellValue(date); + applyDateStyle(cell, layout); + } else cell.setCellValue(value.toString()); + } + + /** 日期样式按模板样式索引缓存,与原 EasyExcel 的默认显示格式一致。 */ + private static void applyDateStyle(Cell cell, Layout layout) { + short original = cell.getCellStyle().getIndex(); + CellStyle style = layout.dateStyles.computeIfAbsent(original, key -> { + CellStyle created = cell.getSheet().getWorkbook().createCellStyle(); + created.cloneStyleFrom(cell.getCellStyle()); + created.setDataFormat(cell.getSheet().getWorkbook().createDataFormat().getFormat("yyyy-MM-dd HH:mm:ss")); + return created; + }); + cell.setCellStyle(style); + } + + /** 模板单元格快照;样式直接属于当前工作簿。 */ + private record CellPattern(int column, CellStyle style, CellType type, Object value) { } + /** 模板行快照,包括原行高。 */ + private record RowPattern(short height, List cells) { } + /** 单个工作簿的模板上下文,生命周期仅覆盖一次导出。 */ + private record Layout(int start, RowPattern detail, List footer, + List tailMerges, Map dateStyles) { } +} diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/style/ExcelExportStyleHandler.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/style/ExcelExportStyleHandler.java new file mode 100644 index 000000000..51632c2af --- /dev/null +++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/style/ExcelExportStyleHandler.java @@ -0,0 +1,193 @@ +package com.bsd.pjcloud.common.excel.style; + +import com.alibaba.excel.constant.OrderConstant; +import com.alibaba.excel.write.handler.CellWriteHandler; +import com.alibaba.excel.write.handler.SheetWriteHandler; +import com.alibaba.excel.write.handler.WorkbookWriteHandler; +import com.alibaba.excel.write.handler.context.CellWriteHandlerContext; +import com.alibaba.excel.write.metadata.holder.WriteSheetHolder; +import com.alibaba.excel.write.metadata.holder.WriteWorkbookHolder; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.xssf.usermodel.XSSFCellStyle; +import org.apache.poi.xssf.usermodel.XSSFColor; +import org.apache.poi.xssf.usermodel.XSSFFont; + +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Map; + +/** + * 可选的 XLSX 样式处理器,每次导出创建一个实例并注册在 ExcelWriterBuilder 上。 + *

构造时固定 token;按原单元格样式和行类型缓存样式,字体仅创建两份。 + * 不扫描明细、不按行创建 POI 样式,保留转换器产生的数据格式与单元格类型。

+ */ +public class ExcelExportStyleHandler implements CellWriteHandler, SheetWriteHandler, WorkbookWriteHandler { + + private final Appearance header; + private final Appearance body; + private final boolean striped; + private final byte[] alternateRowColor; + private final BorderStyle borderStyle; + private final byte[] borderColor; + private final int columnWidth; + private final Map columnWidths; + private final boolean freezeHeader; + private final boolean autoFilter; + private final boolean showGridLines; + private final Map styles = new HashMap<>(); + private final Map layouts = new IdentityHashMap<>(); + private XSSFFont headerFont; + private XSSFFont bodyFont; + + public ExcelExportStyleHandler(ExcelStyleTokens tokens) { + this(tokens, Map.of()); + } + + public ExcelExportStyleHandler(ExcelStyleTokens tokens, Map defaultColumnWidths) { + header = Appearance.of(tokens.getHeader()); + body = Appearance.of(tokens.getBody()); + striped = tokens.isStriped(); + alternateRowColor = rgb(tokens.getAlternateRowColor()); + borderStyle = tokens.getBorderStyle(); + borderColor = rgb(tokens.getBorderColor()); + columnWidth = tokens.getColumnWidth(); + Map widths = new HashMap<>(defaultColumnWidths); + widths.putAll(tokens.getColumnWidths()); + columnWidths = Map.copyOf(widths); + freezeHeader = tokens.isFreezeHeader(); + autoFilter = tokens.isAutoFilter(); + showGridLines = tokens.isShowGridLines(); + } + + @Override + public int order() { + // 在 EasyExcel 样式及 @ColumnWidth 应用之后调整,保留原数据格式。 + return OrderConstant.FILL_STYLE + 1; + } + + @Override + public void afterWorkbookCreate(WriteWorkbookHolder holder) { + headerFont = createFont(holder.getWorkbook(), header); + bodyFont = createFont(holder.getWorkbook(), body); + } + + @Override + public void afterSheetCreate(WriteWorkbookHolder workbook, WriteSheetHolder holder) { + Sheet sheet = holder.getSheet(); + sheet.setDisplayGridlines(showGridLines); + sheet.setDefaultColumnWidth(columnWidth); + sheet.setDefaultRowHeightInPoints(body.rowHeight()); + layouts.put(sheet, new SheetLayout()); + } + + @Override + public void afterCellDispose(CellWriteHandlerContext context) { + if (context.getHead() == null) return; + Cell cell = context.getCell(); + Sheet sheet = cell.getSheet(); + SheetLayout layout = layouts.get(sheet); + boolean isHeader = Boolean.TRUE.equals(context.getHead()); + if (isHeader) { + layout.lastHeaderRow = Math.max(layout.lastHeaderRow, cell.getRowIndex()); + String field = context.getHeadData() == null ? null : context.getHeadData().getFieldName(); + double width = field == null ? columnWidth : columnWidths.getOrDefault(field, (double) columnWidth); + layout.columnWidths.put(cell.getColumnIndex(), (int) Math.round(width * 256)); + } + if (layout.lastStyledRow != cell.getRowIndex()) { + cell.getRow().setHeightInPoints(isHeader ? header.rowHeight() : body.rowHeight()); + layout.lastStyledRow = cell.getRowIndex(); + } + layout.lastRow = Math.max(layout.lastRow, cell.getRowIndex()); + layout.lastColumn = Math.max(layout.lastColumn, cell.getColumnIndex()); + // relativeRowIndex 会在下一批重置,条纹必须使用工作表绝对行号。 + int kind = 1; + if (isHeader) kind = 0; + else if (striped && (cell.getRowIndex() - layout.lastHeaderRow - 1) % 2 == 1) kind = 2; + CellStyle base = cell.getCellStyle(); + StyleKey key = new StyleKey(base.getIndex(), kind); + int styleKind = kind; + cell.setCellStyle(styles.computeIfAbsent(key, ignored -> createStyle(sheet.getWorkbook(), base, styleKind))); + } + + @Override + public void afterWorkbookDispose(WriteWorkbookHolder holder) { + // EasyExcel 在序列化文件前回调;使用记录的边界,兼容已经刷盘的 SXSSF 行。 + try { + layouts.forEach((sheet, layout) -> { + // @ColumnWidth 在每个正文单元格回调中也会执行,因此最后统一设置列宽。 + layout.columnWidths.forEach(sheet::setColumnWidth); + if (layout.lastHeaderRow < 0) return; + if (freezeHeader) sheet.createFreezePane(0, layout.lastHeaderRow + 1); + if (autoFilter) sheet.setAutoFilter(new CellRangeAddress( + layout.lastHeaderRow, layout.lastRow, 0, layout.lastColumn)); + }); + } finally { + styles.clear(); + layouts.clear(); + headerFont = null; + bodyFont = null; + } + } + + private XSSFCellStyle createStyle(Workbook workbook, CellStyle base, int kind) { + XSSFCellStyle style = (XSSFCellStyle) workbook.createCellStyle(); + style.cloneStyleFrom(base); + Appearance appearance = kind == 0 ? header : body; + style.setFont(kind == 0 ? headerFont : bodyFont); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + style.setFillForegroundColor(new XSSFColor(kind == 2 ? alternateRowColor : appearance.backgroundColor(), null)); + style.setAlignment(appearance.alignment()); + style.setVerticalAlignment(VerticalAlignment.CENTER); + style.setWrapText(appearance.wrapText()); + style.setIndention((short) appearance.indent()); + // 正文只保留水平分隔线,表头增加列分隔,避免密集网格干扰阅读。 + style.setBorderTop(BorderStyle.NONE); + style.setBorderBottom(borderStyle); + style.setBorderLeft(kind == 0 ? borderStyle : BorderStyle.NONE); + style.setBorderRight(kind == 0 ? borderStyle : BorderStyle.NONE); + XSSFColor border = new XSSFColor(borderColor, null); + style.setTopBorderColor(border); + style.setBottomBorderColor(border); + style.setLeftBorderColor(border); + style.setRightBorderColor(border); + return style; + } + + private static XSSFFont createFont(Workbook workbook, Appearance appearance) { + if (!(workbook.createFont() instanceof XSSFFont font)) { + throw new IllegalArgumentException("ExcelExportStyleHandler 仅支持 XLSX 导出"); + } + font.setFontName(appearance.fontName()); + font.setFontHeightInPoints((short) appearance.fontSize()); + font.setBold(appearance.bold()); + font.setColor(new XSSFColor(appearance.fontColor(), null)); + return font; + } + + private static byte[] rgb(String color) { + String value = color.startsWith("#") ? color.substring(1) : color; + int rgb = Integer.parseInt(value, 16); + return new byte[] { (byte) (rgb >> 16), (byte) (rgb >> 8), (byte) rgb }; + } + + private record StyleKey(int baseStyle, int kind) {} + + private record Appearance(String fontName, int fontSize, boolean bold, byte[] fontColor, + byte[] backgroundColor, int rowHeight, HorizontalAlignment alignment, + boolean wrapText, int indent) { + private static Appearance of(ExcelStyleTokens.CellTokens tokens) { + return new Appearance(tokens.getFontName(), tokens.getFontSize(), tokens.isBold(), + rgb(tokens.getFontColor()), rgb(tokens.getBackgroundColor()), tokens.getRowHeight(), + tokens.getAlignment(), tokens.isWrapText(), tokens.getIndent()); + } + } + + private static class SheetLayout { + private final Map columnWidths = new HashMap<>(); + private int lastHeaderRow = -1; + private int lastStyledRow = -1; + private int lastRow; + private int lastColumn; + } +} diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/style/ExcelStyleTokens.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/style/ExcelStyleTokens.java new file mode 100644 index 000000000..0a97aa5ee --- /dev/null +++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/style/ExcelStyleTokens.java @@ -0,0 +1,102 @@ +package com.bsd.pjcloud.common.excel.style; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import lombok.Data; +import org.apache.poi.ss.usermodel.BorderStyle; +import org.apache.poi.ss.usermodel.HorizontalAlignment; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * XLSX 导出的全局默认样式,通过 excel.export-style 配置覆盖。 + *

仅供显式接入的导出使用;表头与正文独立配置,颜色为六位 RGB。

+ */ +@Data +public class ExcelStyleTokens { + + @Valid + @NotNull + private CellTokens header = headerDefaults(); + + @Valid + @NotNull + private CellTokens body = new CellTokens(); + + /** 启用正文交替行底纹,按工作表实际行号计算,跨批次连续。 */ + private boolean striped = true; + + @NotNull + @Pattern(regexp = "#?[0-9a-fA-F]{6}") + private String alternateRowColor = "#F3F6FA"; + + @NotNull + private BorderStyle borderStyle = BorderStyle.THIN; + + @NotNull + @Pattern(regexp = "#?[0-9a-fA-F]{6}") + private String borderColor = "#DCE3EC"; + + /** 列宽单位为 Excel 字符宽度,避免对海量明细执行自动列宽扫描。 */ + @Min(1) + @Max(255) + private int columnWidth = 22; + + /** 按 Java 字段名覆盖列宽,优先于业务导出的默认列宽。 */ + @NotNull + private Map columnWidths = new LinkedHashMap<>(); + + private boolean freezeHeader = true; + + private boolean autoFilter = true; + + private boolean showGridLines = false; + + private static CellTokens headerDefaults() { + CellTokens header = new CellTokens(); + header.setBold(true); + header.setFontColor("#FFFFFF"); + header.setBackgroundColor("#1F4E78"); + header.setRowHeight(36); + header.setAlignment(HorizontalAlignment.CENTER); + header.setWrapText(true); + header.setIndent(0); + return header; + } + + /** 表头或正文的语义样式 token;行高与字号的单位均为磅。 */ + @Data + public static class CellTokens { + + @NotBlank + private String fontName = "Microsoft YaHei"; + + @Min(1) + @Max(409) + private int fontSize = 11; + + private boolean bold = false; + + @NotNull + @Pattern(regexp = "#?[0-9a-fA-F]{6}") + private String fontColor = "#243247"; + + @NotNull + @Pattern(regexp = "#?[0-9a-fA-F]{6}") + private String backgroundColor = "#FFFFFF"; + + @Min(1) + @Max(409) + private int rowHeight = 26; + + @NotNull + private HorizontalAlignment alignment = HorizontalAlignment.LEFT; + + private boolean wrapText = false; + + @Min(0) + @Max(15) + private int indent = 1; + } +} diff --git a/pjcloud-common/pjcloud-common-excel/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/pjcloud-common/pjcloud-common-excel/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 000000000..8dcb0fd41 --- /dev/null +++ b/pjcloud-common/pjcloud-common-excel/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,36 @@ +{ + "properties": [ + { + "name": "excel.export-style.header.alignment", + "defaultValue": "CENTER" + }, + { + "name": "excel.export-style.header.background-color", + "defaultValue": "#1F4E78" + }, + { + "name": "excel.export-style.header.bold", + "defaultValue": true + }, + { + "name": "excel.export-style.header.font-color", + "defaultValue": "#FFFFFF" + }, + { + "name": "excel.export-style.header.indent", + "defaultValue": 0 + }, + { + "name": "excel.export-style.header.row-height", + "defaultValue": 36 + }, + { + "name": "excel.export-style.header.wrap-text", + "defaultValue": true + }, + { + "name": "excel.export-style.body.alignment", + "defaultValue": "LEFT" + } + ] +} diff --git a/pjcloud-common/pom.xml b/pjcloud-common/pom.xml index 268b4ca67..bef0fd924 100644 --- a/pjcloud-common/pom.xml +++ b/pjcloud-common/pom.xml @@ -39,6 +39,7 @@ pjcloud-common-data pjcloud-common-datasource pjcloud-common-excel + pjcloud-common-async-task pjcloud-common-encrypt-api pjcloud-common-feign pjcloud-common-gateway diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderTypeEnum.java index fd2380239..2626deb20 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderTypeEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderTypeEnum.java @@ -18,7 +18,7 @@ public enum HardOrderTypeEnum { FOREIGN_TICKET("3", "外来人员"), STRIDE_DEPT("4", "跨单位"), ZY("5", "正元公务餐"), - LB("6", "宁波银行(美好生活)"), + THIRD_APP("6", "第三方app"), FARMER("7", "共富商城(助农)"), TOC_VISITOR("8", "toc访客购买(不记名用户)"), XFJ("9", "消费机支付"), @@ -32,7 +32,7 @@ public enum HardOrderTypeEnum { return Arrays.stream(values()).filter(v -> ObjectUtil.equals(type, v.getType())).findFirst().get(); } - public static final List refundList = List.of(BELOW, RESERVE, FOREIGN_TICKET, ZY, LB, FARMER); + public static final List refundList = List.of(BELOW, RESERVE, FOREIGN_TICKET, ZY, THIRD_APP, FARMER); public static final List payList = List.of(BELOW, RESERVE); diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java index ba0af61a5..e16a2ba11 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java @@ -55,6 +55,9 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeDeptMc private List orderPayTypeCodes; + @Schema(description = "是否有额外支付:true 有,false 无,null 不限制") + private Boolean hasExtraPrice; + private List hardTypeCodes; private List refundStatusCodes; @@ -69,6 +72,9 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeDeptMc */ private String afterSaleNo; + /** 第三方底单退款进度:NONE / PARTIAL / FULL / PROCESSING。 */ + private String thirdRefundState; + /** * 0 yyyy-MM-dd * 1 yyyy-MM diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardThirdOrderRefundDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardThirdOrderRefundDto.java new file mode 100644 index 000000000..225122339 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardThirdOrderRefundDto.java @@ -0,0 +1,30 @@ +package com.bsd.pjcloud.hard.api.dto.order; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** 第三方应用底单按金额退款;金额单位为分。 */ +@Data +public class HardThirdOrderRefundDto { + @NotNull(message = "订单编号不能为空") + private Long orderId; + + @NotNull(message = "退款金额不能为空") + @Min(value = 1, message = "退款金额至少为1分") + private Integer refundPrice; + + @NotBlank(message = "退款请求编号不能为空") + @Pattern(regexp = "[A-Za-z0-9_-]{16,40}", message = "退款请求编号格式不正确") + private String requestNo; + + @NotBlank(message = "退款原因不能为空") + @Size(max = 200, message = "退款原因不能超过200字") + private String applyReason; + + @Size(max = 255, message = "补充说明不能超过255字") + private String applyDescription; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderStatisticsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderStatisticsVo.java new file mode 100644 index 000000000..7e9c87c25 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderStatisticsVo.java @@ -0,0 +1,67 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** 订单列表实时统计。金额均为整数分,以字符串返回,避免累计金额溢出或前端精度丢失。 */ +@Data +public class HardOrderStatisticsVo { + + private Totals summary = new Totals(); + private List daily = new ArrayList<>(); + private List payTypes = new ArrayList<>(); + private Totals withoutPayTime = new Totals(); + private LocalDateTime generatedAt; + + @Data + public static class Totals { + @JsonSerialize(using = ToStringSerializer.class) + private Long orderCount = 0L; + @JsonSerialize(using = ToStringSerializer.class) + private BigDecimal totalPrice = BigDecimal.ZERO; + @JsonSerialize(using = ToStringSerializer.class) + private BigDecimal extraPrice = BigDecimal.ZERO; + @JsonSerialize(using = ToStringSerializer.class) + private BigDecimal couponPrice = BigDecimal.ZERO; + @JsonSerialize(using = ToStringSerializer.class) + private BigDecimal payPrice = BigDecimal.ZERO; + @JsonSerialize(using = ToStringSerializer.class) + private BigDecimal refundPrice = BigDecimal.ZERO; + + @JsonSerialize(using = ToStringSerializer.class) + public BigDecimal getNetPrice() { + return payPrice.subtract(refundPrice); + } + } + + @Data + @EqualsAndHashCode(callSuper = true) + public static class Daily extends Totals { + private LocalDate date; + } + + @Data + @EqualsAndHashCode(callSuper = true) + public static class PayType extends Totals { + private String payType; + } + + @Data + @EqualsAndHashCode(callSuper = true) + public static class Merchant extends Totals { + private Long merchantId; + private String merchantName; + } + + public record MerchantPage(List records, int current, int size, boolean hasMore, + LocalDateTime generatedAt) { + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardThirdOrderDetailVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardThirdOrderDetailVo.java new file mode 100644 index 000000000..b6ec3c96e --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardThirdOrderDetailVo.java @@ -0,0 +1,14 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity; +import lombok.Data; + +import java.util.List; + +/** 第三方支付底单及其完整商品、售后记录。 */ +@Data +public class HardThirdOrderDetailVo { + private HardOrderVo order; + private List products; + private List refunds; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardThirdOrderExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardThirdOrderExcelVo.java new file mode 100644 index 000000000..355b7665f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardThirdOrderExcelVo.java @@ -0,0 +1,77 @@ +package com.bsd.pjcloud.hard.api.vo.order.excel; + +import com.alibaba.excel.annotation.ExcelProperty; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Arrays; + +/** 第三方底单专用导出;长编号按文本输出,金额单位为元。 */ +@Data +public class HardThirdOrderExcelVo { + @ExcelProperty("第三方订单号") + private String thirdOrderNo; + @ExcelProperty("平台底单号") + private String platformOrderId; + @ExcelProperty("支付用户") + private String username; + @ExcelProperty("手机号") + private String mobile; + @ExcelProperty("用户部门") + private String userDeptName; + @ExcelProperty("支付商户") + private String merchantName; + @ExcelProperty("钱包编号") + private String walletId; + @ExcelProperty("商品描述") + private String productDesc; + @ExcelProperty("商品总额(元)") + private BigDecimal totalPrice; + @ExcelProperty("支付差额(元)") + private BigDecimal extraPrice; + @ExcelProperty("支付金额(元)") + private BigDecimal payPrice; + @ExcelProperty("累计退款(元)") + private BigDecimal refundPrice; + @ExcelProperty("剩余可退(元)") + private BigDecimal remainingPrice; + @ExcelProperty("底单状态") + private String status; + @ExcelProperty("创建时间") + private String createTime; + @ExcelProperty("支付时间") + private String payTime; + @ExcelProperty("最新退款时间") + private String refundTime; + + public static HardThirdOrderExcelVo from(HardOrderVo order) { + HardThirdOrderExcelVo row = new HardThirdOrderExcelVo(); + row.setThirdOrderNo(order.getOrderNo()); + row.setPlatformOrderId(String.valueOf(order.getId())); + row.setUsername(order.getUsername()); + row.setMobile(order.getUserMobile()); + row.setUserDeptName(order.getUserDeptName()); + row.setMerchantName(order.getMerchantName()); + row.setWalletId(order.getWalletId() == null ? "" : String.valueOf(order.getWalletId())); + row.setProductDesc(order.getProductDesc()); + row.setTotalPrice(yuan(order.getTotalPrice())); + row.setExtraPrice(yuan(order.getExtraPrice())); + row.setPayPrice(yuan(order.getPayPrice())); + row.setRefundPrice(yuan(order.getRefundPrice())); + row.setRemainingPrice(order.getPayTime() == null ? BigDecimal.ZERO : row.getPayPrice().subtract(row.getRefundPrice()).max(BigDecimal.ZERO)); + row.setStatus(Arrays.stream(HardOrderStatusEnum.values()).filter(item -> item.getType().equals(order.getOrderStatus())) + .map(HardOrderStatusEnum::getDescription).findFirst().orElse(order.getOrderStatus())); + if (row.getRefundPrice().signum() > 0) row.setStatus(row.getRefundPrice().compareTo(row.getPayPrice()) >= 0 ? "已全额退款" : "已部分退款"); + if ("10".equals(order.getRefundStatus())) row.setStatus("退款处理中"); + row.setCreateTime(order.getCreateTime() == null ? "" : order.getCreateTime().toString().replace('T', ' ')); + row.setPayTime(order.getPayTime() == null ? "" : order.getPayTime().toString().replace('T', ' ')); + row.setRefundTime(order.getRefundTime() == null ? "" : order.getRefundTime().toString().replace('T', ' ')); + return row; + } + + private static BigDecimal yuan(Integer cents) { + return BigDecimal.valueOf(cents == null ? 0 : cents).movePointLeft(2); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/pom.xml b/pjcloud-hardware/pjcloud-hardware-biz/pom.xml index a69771160..b6389104e 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/pom.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/pom.xml @@ -13,6 +13,16 @@ pjcloud-hardware-biz + + + com.bsd + pjcloud-common-async-task + + + com.h2database + h2 + test + com.bsd pjcloud-common-encrypt-api diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/config/LbCallbackConfig.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/config/ThirdAppCallbackConfig.java similarity index 74% rename from pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/config/LbCallbackConfig.java rename to pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/config/ThirdAppCallbackConfig.java index 383a73cf4..b9a742edb 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/config/LbCallbackConfig.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/config/ThirdAppCallbackConfig.java @@ -7,11 +7,17 @@ import org.springframework.stereotype.Component; @Data @ConfigurationProperties(prefix = "pay-callback") @Component -public class LbCallbackConfig { +public class ThirdAppCallbackConfig { /** * 宁波银行(美好生活)商户id */ private Long lbMchId; + + /** + * 农发农产品商户id + */ + private Long nffcpMchId; + } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java index 927fcd752..ced3262ff 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java @@ -28,7 +28,6 @@ import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; import com.bsd.pjcloud.hard.api.dto.app.HardAppCreateOrderScancode; -import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto; import com.bsd.pjcloud.hard.api.dto.hard.HardOfflinePayDto; import com.bsd.pjcloud.hard.api.dto.order.HardDoorManualSettleDto; import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; @@ -38,11 +37,14 @@ import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateFarmerOrderDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateOrderDto; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderStatisticsVo; +import com.bsd.pjcloud.hard.service.order.HardOrderQuerySupport; +import com.bsd.pjcloud.hard.service.order.HardOrderStatisticsService; import com.bsd.pjcloud.hard.api.vo.order.excel.HardOrderExcelVo; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; -import com.bsd.pjcloud.hard.service.HardManagerService; import com.bsd.pjcloud.hard.service.HardOrderService; import com.bsd.pjcloud.hard.service.HardOrderSyncService; +import com.bsd.pjcloud.hard.service.HardManagerService; import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; @@ -77,14 +79,19 @@ import java.util.Map; @SecurityRequirement(name = HttpHeaders.AUTHORIZATION) public class HardOrderController { + /** 复用原模板的后台分批导出。 */ + private final com.bsd.pjcloud.hard.service.impl.order.HardBatchExportService batchExportService; + private final HardOrderService hardwareOrderService; + private final HardOrderQuerySupport orderQuerySupport; + private final HardOrderStatisticsService orderStatisticsService; private final HardManagerService hardManagerService; private final HardOrderSyncService hardOrderSyncService; private final HardOrderMapper hardOrderMapper; - private final RemoteMchService mchService; private final RemoteUserService userService; + private final RemoteMchService mchService; /* ************************************* start 远程调用 start ************************************* */ @Inner @@ -270,43 +277,32 @@ public class HardOrderController { public R pageJoin(@ParameterObject Page page, @RequestBody HardOrderDto dto) { DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL); dataScope.setScopeDeptName("merchant_dept_id"); - PjcloudUser user = SecurityUtils.getUser(); - CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user); - dto.setMchTypeCodes(hardTypeInfo.getHardTypeList()); - // 查询商户和下面的子商户 - List mchDtoIds = dto.getMchIds(); - if (ObjectUtil.isNotEmpty(mchDtoIds) && mchDtoIds.size() == 1) { - Long mchId = mchDtoIds.get(0); - List mchList = mchService.getByParentId(mchId).getData(); - if (ObjectUtil.isNotEmpty(mchList)) { - List mchIds = mchList.stream().map(MchManageVo::getId).toList(); - mchDtoIds.addAll(mchIds); - dto.setMchIds(mchDtoIds); - } - } + orderQuerySupport.prepare(dto); return R.ok(hardwareOrderService.scopePageList(page, dto, dataScope)); } + @Operation(summary = "当前筛选条件的订单实时统计") + @PostMapping("/statistics") + @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')") + public R statistics(@RequestBody HardOrderDto dto) { + return R.ok(orderStatisticsService.overview(dto)); + } + + @Operation(summary = "当前筛选条件的商户消费实时统计") + @PostMapping("/statistics/merchants") + @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')") + public R merchantStatistics(@RequestBody HardOrderDto dto, + @RequestParam(defaultValue = "1") int current, @RequestParam(defaultValue = "20") int size) { + return R.ok(orderStatisticsService.merchants(dto, current, size)); + } + @Operation(summary = "分页查询未识别订单", description = "分页查询未识别订单") @PostMapping("/no-recog/page-join") @PreAuthorize("@pms.hasPermission('hard_hardwareOrderNoRecog_view')") public R> noRecogPageJoin(@ParameterObject Page page, @RequestBody HardOrderDto dto) { DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL); dataScope.setScopeDeptName("merchant_dept_id"); - PjcloudUser user = SecurityUtils.getUser(); - CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user); - dto.setMchTypeCodes(hardTypeInfo.getHardTypeList()); - // 查询商户和下面的子商户 - List mchDtoIds = dto.getMchIds(); - if (ObjectUtil.isNotEmpty(mchDtoIds) && mchDtoIds.size() == 1) { - Long mchId = mchDtoIds.get(0); - List mchList = mchService.getByParentId(mchId).getData(); - if (ObjectUtil.isNotEmpty(mchList)) { - List mchIds = mchList.stream().map(MchManageVo::getId).toList(); - mchDtoIds.addAll(mchIds); - dto.setMchIds(mchDtoIds); - } - } + orderQuerySupport.prepare(dto); return R.ok(hardwareOrderService.noRecogScopePageJoin(page, dto, dataScope)); } @@ -345,75 +341,14 @@ public class HardOrderController { @GetMapping("/export") @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_export')") - public void export(HttpServletResponse response, HardOrderDto dto) throws IOException { - DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL); - dataScope.setScopeDeptName("merchant_dept_id"); - PjcloudUser user = SecurityUtils.getUser(); - CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user); - dto.setMchTypeCodes(hardTypeInfo.getHardTypeList()); - // 查询商户和下面的子商户 - List mchDtoIds = dto.getMchIds(); - if (ObjectUtil.isNotEmpty(mchDtoIds) && mchDtoIds.size() == 1) { - Long mchId = mchDtoIds.get(0); - List mchList = mchService.getByParentId(mchId).getData(); - if (ObjectUtil.isNotEmpty(mchList)) { - List mchIds = mchList.stream().map(MchManageVo::getId).toList(); - mchDtoIds.addAll(mchIds); - dto.setMchIds(mchDtoIds); - } - } - List list = hardwareOrderService.scopeList(dto, DataScope.of()); - List excelList = BeanUtils.toBean(list, HardOrderExcelVo.class); - try (ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).withTemplate(ResourceUtil.getStream("file/订单交易汇总.xlsx")).build()) { - WriteSheet writeSheet = EasyExcel.writerSheet().build(); - FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build(); - Map map = new HashMap(); - if (ObjectUtil.isNotEmpty(dto.getPayTimeList())) { - map.put("timeSpan", " 到 "); - map.put("startTime", LocalDateTimeUtil.format(dto.getPayTimeList()[0], DatePattern.NORM_DATETIME_PATTERN)); - map.put("endTime", LocalDateTimeUtil.format(dto.getPayTimeList()[1], DatePattern.NORM_DATETIME_PATTERN)); - } - if (ObjectUtil.isNotEmpty(excelList)) { - BigDecimal totalPriceSum = BigDecimal.ZERO; - BigDecimal extraPriceSum = BigDecimal.ZERO; - BigDecimal payPriceSum = BigDecimal.ZERO; - Long lineNumber = 1L; - for (HardOrderExcelVo v : excelList) { - v.setLineNumber(lineNumber); - lineNumber++; - if (ObjectUtil.isNotEmpty(v.getTotalPrice())) { - totalPriceSum = totalPriceSum.add(BigDecimal.valueOf(v.getTotalPrice())); - v.setTotalPrice(BigDecimal.valueOf(v.getTotalPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue()); - } - if (ObjectUtil.isNotEmpty(v.getExtraPrice())) { - extraPriceSum = extraPriceSum.add(BigDecimal.valueOf(v.getExtraPrice())); - v.setExtraPrice(BigDecimal.valueOf(v.getExtraPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue()); - } - if (ObjectUtil.isNotEmpty(v.getPayPrice())) { - payPriceSum = payPriceSum.add(BigDecimal.valueOf(v.getPayPrice())); - v.setPayPrice(BigDecimal.valueOf(v.getPayPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue()); - } - if (ObjectUtil.isNotEmpty(v.getHourType())) { - HardHourTypeEnum hardHourTypeEnum = HardHourTypeEnum.valueOfByType(v.getHourType()); - if (ObjectUtil.isNotEmpty(hardHourTypeEnum)) { - v.setHourType(hardHourTypeEnum.getDescription()); - } else { - v.setHourType(""); - } - } - if (ObjectUtil.isNotEmpty(v.getPayType())) { - HardPayTypeEnum hardPayTypeEnum = HardPayTypeEnum.valueOfByType(v.getPayType()); - if (ObjectUtil.isNotEmpty(hardPayTypeEnum)) { - v.setPayType(hardPayTypeEnum.getDescription()); - } - } - } - map.put("totalPriceSum", totalPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - map.put("extraPriceSum", extraPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - map.put("payPriceSum", payPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - } - excelWriter.fill(map, writeSheet); - excelWriter.fill(excelList, fillConfig, writeSheet); - } + public void export(HttpServletResponse response, HardOrderDto dto) throws Exception { + batchExportService.order(response, dto); } -} \ No newline at end of file + + /** 提交分批导出任务,任务成功后返回完整文件地址。 */ + @PostMapping("/export-async") + @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_export')") + public R exportAsync(@RequestBody HardOrderDto dto) { + return R.ok(batchExportService.submitOrder(dto), "导出任务已提交"); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java index 6a5975bae..7cfb3e9ee 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java @@ -57,6 +57,9 @@ import java.util.Map; @SecurityRequirement(name = HttpHeaders.AUTHORIZATION) public class HardOrderTotalController { + /** 复用原模板的后台分批导出。 */ + private final com.bsd.pjcloud.hard.service.impl.order.HardBatchExportService batchExportService; + private final HardOrderService hardOrderService; private final HardOrderTotalService hardOrderTotalService; private final HardManagerService hardManagerService; @@ -342,115 +345,30 @@ public class HardOrderTotalController { @PostMapping("/export-cs") @SysLog("商户交易记录导出") @PreAuthorize("@pms.hasPermission('hard_hardTotal_export_cs')") - public void exportTk(HttpServletResponse response, @RequestBody HardOrderDto dto) throws IOException { - dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes()); - // 查询商户和下面的子商户 - List mchDtoIds = new ArrayList<>(); - if (ObjectUtil.isNotEmpty(dto.getMerchantId())) { - List mchList = mchService.getByParentId(dto.getMerchantId()).getData(); - if (ObjectUtil.isNotEmpty(mchList)) { - List mchIds = mchList.stream().map(MchManageVo::getId).toList(); - mchDtoIds.add(dto.getMerchantId()); - mchDtoIds.addAll(mchIds); - dto.setMerchantId(null); - dto.setMchIds(mchDtoIds); - } - } - List list = hardOrderTotalService.listCs(dto); - try (ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).withTemplate(ResourceUtil.getStream("file/商户交易金额汇总.xlsx")).build()) { - WriteSheet writeSheet = EasyExcel.writerSheet().build(); - FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build(); - Map map = new HashMap(); - if (ObjectUtil.isNotEmpty(dto.getPayTimeList())) { - map.put("timeSpan", " 到 "); - map.put("startTime", LocalDateTimeUtil.format(dto.getPayTimeList()[0], DatePattern.NORM_DATETIME_PATTERN)); - map.put("endTime", LocalDateTimeUtil.format(dto.getPayTimeList()[1], DatePattern.NORM_DATETIME_PATTERN)); - } - if (ObjectUtil.isNotEmpty(list)) { - // 总消费 - BigDecimal totalPayPrice = BigDecimal.ZERO; - BigDecimal totalPayCount = BigDecimal.ZERO; - // 总退款 - BigDecimal totalRefundPrice = BigDecimal.ZERO; - BigDecimal totalRefundCount = BigDecimal.ZERO; - // 总盈利 - BigDecimal totalProfitPrice = BigDecimal.ZERO; - BigDecimal totalProfitCount = BigDecimal.ZERO; - // 总抽成 - BigDecimal mchPlatformRankeTotalPrice = BigDecimal.ZERO; - Long lineNumber = 1L; - for (HardOrderCsVo v : list) { - v.setLineNumber(lineNumber); - lineNumber++; - if (ObjectUtil.isNotEmpty(v.getHardType())) { - v.setHardType(HardTypeEnum.valueOfByType(v.getHardType()).getDescription()); - } - // 总消费 - if (ObjectUtil.isNotEmpty(v.getTotalPayPrice())) { - totalPayPrice = totalPayPrice.add(v.getTotalPayPrice()); - v.setTotalPayPrice(v.getTotalPayPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP)); - } - if (ObjectUtil.isNotEmpty(v.getTotalPayCount())) { - totalPayCount = totalPayCount.add(v.getTotalPayCount()); - } - // 总退款 - if (ObjectUtil.isNotEmpty(v.getTotalRefundPrice())) { - totalRefundPrice = totalRefundPrice.add(v.getTotalRefundPrice()); - v.setTotalRefundPrice(v.getTotalRefundPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP)); - } - if (ObjectUtil.isNotEmpty(v.getTotalRefundCount())) { - totalRefundCount = totalRefundCount.add(v.getTotalRefundCount()); - } - // 总盈利 - if (ObjectUtil.isNotEmpty(v.getTotalProfitPrice())) { - totalProfitPrice = totalProfitPrice.add(v.getTotalProfitPrice()); - v.setTotalProfitPrice(v.getTotalProfitPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP)); - } - if (ObjectUtil.isNotEmpty(v.getTotalProfitCount())) { - totalProfitCount = totalProfitCount.add(v.getTotalProfitCount()); - } - // 平台抽成 - if (ObjectUtil.isNotEmpty(v.getMchPlatformRankePrice())) { - mchPlatformRankeTotalPrice = mchPlatformRankeTotalPrice.add(v.getMchPlatformRankePrice()); - v.setMchPlatformRankePrice(v.getMchPlatformRankePrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP)); - } - } - // 总消费 - map.put("computeTotalPayPrice", totalPayPrice.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - map.put("computeTotalPayCount", totalPayCount.toString()); - // 总退款 - map.put("computeTotalRefundPrice", totalRefundPrice.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - map.put("computeTotalRefundCount", totalRefundCount.toString()); - // 总盈利 - map.put("computeTotalProfitPrice", totalProfitPrice.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - map.put("computeTotalProfitCount", totalProfitCount.toString()); - // 总抽成 - map.put("mchPlatformRankeTotalPrice", mchPlatformRankeTotalPrice.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - } - excelWriter.fill(map, writeSheet); - excelWriter.fill(list, fillConfig, writeSheet); - } + public void exportTk(HttpServletResponse response, @RequestBody HardOrderDto dto) throws Exception { + batchExportService.merchant(response, dto, false); + } + + /** 提交分批导出任务,任务成功后返回完整文件地址。 */ + @PostMapping("/export-cs-async") + @PreAuthorize("@pms.hasPermission('hard_hardTotal_export_cs')") + public R exportTkAsync(@RequestBody HardOrderDto dto) { + return R.ok(batchExportService.submitMerchant(dto, false), "导出任务已提交"); } @Operation(summary = "商户交易记录导出(按月)", description = "商户交易记录导出(按月)") @PostMapping("/export-cs-by-month") @SysLog("商户交易记录导出(按月)") @PreAuthorize("@pms.hasPermission('hard_hardTotal_export_cs')") - public void exportCsByMonth(HttpServletResponse response, @RequestBody HardOrderDto dto) { - dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes()); - // 查询商户和下面的子商户 - List mchDtoIds = new ArrayList<>(); - if (ObjectUtil.isNotEmpty(dto.getMerchantId())) { - List mchList = mchService.getByParentId(dto.getMerchantId()).getData(); - if (ObjectUtil.isNotEmpty(mchList)) { - List mchIds = mchList.stream().map(MchManageVo::getId).toList(); - mchDtoIds.add(dto.getMerchantId()); - mchDtoIds.addAll(mchIds); - dto.setMerchantId(null); - dto.setMchIds(mchDtoIds); - } - } - hardOrderTotalService.exportCsByMonth(response, dto); + public void exportCsByMonth(HttpServletResponse response, @RequestBody HardOrderDto dto) throws Exception { + batchExportService.merchant(response, dto, true); + } + + /** 提交分批导出任务,任务成功后返回完整文件地址。 */ + @PostMapping("/export-cs-by-month-async") + @PreAuthorize("@pms.hasPermission('hard_hardTotal_export_cs')") + public R exportCsByMonthAsync(@RequestBody HardOrderDto dto) { + return R.ok(batchExportService.submitMerchant(dto, true), "导出任务已提交"); } @Operation(summary = "支付方式交易记录", description = "支付方式交易记录") @@ -529,116 +447,14 @@ public class HardOrderTotalController { @PostMapping("/export-dept") @SysLog("部门交易记录导出") @PreAuthorize("@pms.hasPermission('hard_hardTotal_export_dept')") - public void exportDept(HttpServletResponse response, @RequestBody HardDeptTotalDto dto) throws IOException { - List list = hardOrderTotalService.pageListDept(dto); - try (ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).withTemplate(ResourceUtil.getStream("file/部门交易金额汇总.xlsx")).build()) { - WriteSheet writeSheet = EasyExcel.writerSheet().build(); - FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build(); - Map map = new HashMap(); - if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) { - map.put("timeSpan", " 到 "); - map.put("startTime", LocalDateTimeUtil.format(dto.getCreateTimeList()[0], DatePattern.NORM_DATETIME_PATTERN)); - map.put("endTime", LocalDateTimeUtil.format(dto.getCreateTimeList()[1], DatePattern.NORM_DATETIME_PATTERN)); - } - if (ObjectUtil.isNotEmpty(list)) { - // 批量充值 - BigDecimal walletPriceSum = BigDecimal.ZERO; - BigDecimal walletCountSum = BigDecimal.ZERO; - // 批量扣减 - BigDecimal walletReducePriceSum = BigDecimal.ZERO; - BigDecimal walletReduceCountSum = BigDecimal.ZERO; - // 单次充值 - BigDecimal walletOncePriceSum = BigDecimal.ZERO; - BigDecimal walletOnceCountSum = BigDecimal.ZERO; - // 单次扣减 - BigDecimal walletOnceReducePriceSum = BigDecimal.ZERO; - BigDecimal walletOnceReduceCountSum = BigDecimal.ZERO; + public void exportDept(HttpServletResponse response, @RequestBody HardDeptTotalDto dto) throws Exception { + batchExportService.department(response, dto); + } - BigDecimal orderPayPriceSum = BigDecimal.ZERO; - BigDecimal orderPayCountSum = BigDecimal.ZERO; - - BigDecimal orderRefundPriceSum = BigDecimal.ZERO; - BigDecimal orderRefundContSum = BigDecimal.ZERO; - // 盈利 - BigDecimal orderProfitPriceSum = BigDecimal.ZERO; - BigDecimal orderProfitContSum = BigDecimal.ZERO; - // 退还各自食堂金额 - BigDecimal walletReturnCanteenPriceSum = BigDecimal.ZERO; - BigDecimal walletReturnCanteenCountSum = BigDecimal.ZERO; - - Long lineNumber = 1L; - for (HardDeptTotalVo v : list) { - v.setLineNumber(lineNumber); - lineNumber++; - // 批量充值 - if (ObjectUtil.isNotEmpty(v.getWalletPrice())) { - walletPriceSum = walletPriceSum.add(v.getWalletPrice()); - v.setWalletPrice(v.getWalletPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP)); - } - // 批量扣减 - if (ObjectUtil.isNotEmpty(v.getWalletReducePrice())) { - walletReducePriceSum = walletReducePriceSum.add(BigDecimal.valueOf(v.getWalletReducePrice())); - v.setWalletReducePrice(BigDecimal.valueOf(v.getWalletReducePrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).longValue()); - } - // 单次充值 - if (ObjectUtil.isNotEmpty(v.getWalletOncePrice())) { - walletOncePriceSum = walletOncePriceSum.add(v.getWalletOncePrice()); - v.setWalletOncePrice(v.getWalletOncePrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP)); - } - // 单次扣减 - if (ObjectUtil.isNotEmpty(v.getWalletOnceReducePrice())) { - walletOnceReducePriceSum = walletOnceReducePriceSum.add(v.getWalletOnceReducePrice()); - v.setWalletOnceReducePrice(v.getWalletOnceReducePrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP)); - } - // 消费金额 - if (ObjectUtil.isNotEmpty(v.getOrderPayPrice())) { - orderPayPriceSum = orderPayPriceSum.add(v.getOrderPayPrice()); - v.setOrderPayPrice(v.getOrderPayPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP)); - } - // - if (ObjectUtil.isNotEmpty(v.getOrderRefundPrice())) { - orderRefundPriceSum = orderRefundPriceSum.add(v.getOrderRefundPrice()); - v.setOrderRefundPrice(v.getOrderRefundPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP)); - } - // - if (ObjectUtil.isNotEmpty(v.getOrderProfitPrice())) { - orderProfitPriceSum = orderProfitPriceSum.add(v.getOrderProfitPrice()); - v.setOrderProfitPrice(v.getOrderProfitPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP)); - } - // 退还各自食堂金额 - if (ObjectUtil.isNotEmpty(v.getWalletReturnCanteenPrice())) { - walletReturnCanteenPriceSum = walletReturnCanteenPriceSum.add(v.getWalletReturnCanteenPrice()); - v.setWalletReturnCanteenPrice(v.getWalletReturnCanteenPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP)); - } - // 批量充值 - map.put("walletPriceSum", walletPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - map.put("walletCountSum", walletCountSum.toString()); - // 批量扣减 - map.put("walletReducePriceSum", walletReducePriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - map.put("walletReduceCountSum", walletReduceCountSum.toString()); - // 单次充值 - map.put("walletOncePriceSum", walletOncePriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - map.put("walletOnceCountSum", walletOnceCountSum.toString()); - // 单次扣减 - map.put("walletOnceReducePriceSum", walletOnceReducePriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - map.put("walletOnceReduceCountSum", walletOnceReduceCountSum.toString()); - - map.put("orderPayPriceSum", orderPayPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - map.put("orderPayCountSum", orderPayCountSum.toString()); - - map.put("orderRefundPriceSum", orderRefundPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - map.put("orderRefundContSum", orderRefundContSum.toString()); - - map.put("orderProfitPriceSum", orderProfitPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - map.put("orderProfitContSum", orderProfitContSum.toString()); - - // 退还各自食堂金额 - map.put("walletReturnCanteenPriceSum", walletReturnCanteenPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - map.put("walletReturnCanteenCountSum", walletReturnCanteenCountSum.toString()); - } - } - excelWriter.fill(map, writeSheet); - excelWriter.fill(list, fillConfig, writeSheet); - } + /** 提交分批导出任务,任务成功后返回完整文件地址。 */ + @PostMapping("/export-dept-async") + @PreAuthorize("@pms.hasPermission('hard_hardTotal_export_dept')") + public R exportDeptAsync(@RequestBody HardDeptTotalDto dto) { + return R.ok(batchExportService.submitDepartment(dto), "导出任务已提交"); } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardThirdOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardThirdOrderController.java new file mode 100644 index 000000000..2fac812c0 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardThirdOrderController.java @@ -0,0 +1,63 @@ +package com.bsd.pjcloud.hard.controller.order; + +import com.alibaba.excel.EasyExcel; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.idempotent.annotation.Idempotent; +import com.bsd.pjcloud.common.security.util.SecurityUtils; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; +import com.bsd.pjcloud.hard.api.dto.order.HardThirdOrderRefundDto; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; +import com.bsd.pjcloud.hard.api.vo.order.HardThirdOrderDetailVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardThirdOrderExcelVo; +import com.bsd.pjcloud.hard.service.HardThirdOrderService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/hard-third-order") +@Tag(name = "第三方应用支付底单") +public class HardThirdOrderController { + private final HardThirdOrderService thirdOrderService; + + @PostMapping("/page") + @Operation(summary = "分页查询第三方支付底单") + @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')") + public R> page(@ParameterObject Page page, @RequestBody HardOrderDto dto) { + return R.ok(thirdOrderService.page(page, dto, SecurityUtils.getUser())); + } + + @GetMapping("/{id}") + @Operation(summary = "查询第三方支付底单完整生命周期") + @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')") + public R detail(@PathVariable Long id) { + return R.ok(thirdOrderService.detail(id, SecurityUtils.getUser())); + } + + @GetMapping("/export") + @Operation(summary = "导出第三方支付底单") + @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_export')") + public void export(@ParameterObject HardOrderDto dto, HttpServletResponse response) throws IOException { + var rows = thirdOrderService.list(dto, SecurityUtils.getUser()).stream().map(HardThirdOrderExcelVo::from).toList(); + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setHeader("Content-Disposition", "attachment; filename=third-payment-orders.xlsx"); + EasyExcel.write(response.getOutputStream(), HardThirdOrderExcelVo.class).sheet("第三方支付底单").doWrite(rows); + } + + @PostMapping("/refund") + @Operation(summary = "第三方支付底单按金额退款") + @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')") + @Idempotent(expireTime = 5, key = "'/hard-third-order/refund' + #dto.getOrderId()", info = "正在退款中,请勿重复操作") + public R refund(@Validated @RequestBody HardThirdOrderRefundDto dto) { + return R.ok(thirdOrderService.refund(dto, SecurityUtils.getUser())); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java index 3c507f54c..3132e1e42 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java @@ -176,6 +176,9 @@ public interface HardOrderMapper extends PjcloudBaseMapper { * @param dto 订单状态、商户、时间和钱包类型筛选条件 * @return 逐笔支付明细,bizId 为订单 ID 字符串 */ + /** 导出分页读取原始支付明细,按订单主键稳定排序。 */ + Page pagePayStatisticsDetails(Page page, @Param("dto") HardOrderSumAndCountDto dto); + List listPayStatisticsDetails(@Param("dto") HardOrderSumAndCountDto dto); List sumAndCountTotalByHourType(@Param("dto") HardOrderSumAndCountDto dto); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderStatisticsMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderStatisticsMapper.java new file mode 100644 index 000000000..51e11e45c --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderStatisticsMapper.java @@ -0,0 +1,20 @@ +package com.bsd.pjcloud.hard.mapper.order; + +import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderStatisticsVo; +import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsRow; +import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsScope; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface HardOrderStatisticsMapper { + List overview(@Param("dto") HardOrderDto dto, + @Param("scope") HardOrderStatisticsScope scope); + + List merchants(@Param("dto") HardOrderDto dto, + @Param("scope") HardOrderStatisticsScope scope, + @Param("offset") long offset, @Param("limit") int limit); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardOrderStatisticsRow.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardOrderStatisticsRow.java new file mode 100644 index 000000000..8b2c2bff4 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardOrderStatisticsRow.java @@ -0,0 +1,15 @@ +package com.bsd.pjcloud.hard.mapper.order.model; + +import com.bsd.pjcloud.hard.api.vo.order.HardOrderStatisticsVo; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDate; + +/** 概览一次扫描返回的支付日期、支付方式聚合行。 */ +@Data +@EqualsAndHashCode(callSuper = true) +public class HardOrderStatisticsRow extends HardOrderStatisticsVo.Totals { + private LocalDate date; + private String payType; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardOrderStatisticsScope.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardOrderStatisticsScope.java new file mode 100644 index 000000000..c1e857195 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardOrderStatisticsScope.java @@ -0,0 +1,7 @@ +package com.bsd.pjcloud.hard.mapper.order.model; + +import java.util.List; + +/** 仅由服务端生成的权限快照;不使用 DataScope 类型,避免聚合后再次被外层权限过滤。 */ +public record HardOrderStatisticsScope(Long tenantId, boolean all, List deptIds, String username) { +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardThirdOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardThirdOrderService.java new file mode 100644 index 000000000..ef9b542dd --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardThirdOrderService.java @@ -0,0 +1,21 @@ +package com.bsd.pjcloud.hard.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; +import com.bsd.pjcloud.hard.api.dto.order.HardThirdOrderRefundDto; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; +import com.bsd.pjcloud.hard.api.vo.order.HardThirdOrderDetailVo; + +import java.util.List; + +/** 第三方应用支付底单的后台管理。 */ +public interface HardThirdOrderService { + Page page(Page page, HardOrderDto dto, PjcloudUser user); + + List list(HardOrderDto dto, PjcloudUser user); + + HardThirdOrderDetailVo detail(Long id, PjcloudUser user); + + boolean refund(HardThirdOrderRefundDto dto, PjcloudUser user); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchExportService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchExportService.java new file mode 100644 index 000000000..6382a7739 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchExportService.java @@ -0,0 +1,380 @@ +package com.bsd.pjcloud.hard.service.impl.order; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.feign.RemoteFileService; +import com.bsd.pjcloud.common.asynctask.service.*; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; +import com.bsd.pjcloud.common.core.util.BeanUtils; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.excel.stream.*; +import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; +import com.bsd.pjcloud.hard.api.constant.order.*; +import com.bsd.pjcloud.hard.api.dto.order.*; +import com.bsd.pjcloud.hard.api.vo.order.*; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardOrderExcelVo; +import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; +import com.bsd.pjcloud.hard.service.HardOrderService; +import com.bsd.pjcloud.hard.service.order.HardOrderQuerySupport; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.apache.poi.ss.usermodel.*; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.*; +import java.util.*; +import java.util.function.Function; + +/** 订单与交易统计的后台分批导出;保留当前项目模板及统计口径。 */ +@Service +@RequiredArgsConstructor +public class HardBatchExportService { + private final AsyncTaskExecutorService tasks; + private final HardOrderService orders; + private final HardOrderQuerySupport orderQuerySupport; + private final HardOrderTotalServiceImpl totals; + private final HardOrderMapper mapper; + private final RemoteMchService mchService; + private final RemoteFileService files; + + /** 提交订单明细;原查询准备器继续控制当前用户的商户及筛选范围。 */ + public AsyncTaskProgressVO submitOrder(HardOrderDto dto) { + HardOrderDto query = BeanUtil.toBean(dto, HardOrderDto.class); + return tasks.submitWithResult("hard:export:order", "订单记录导出", 1, + reporter -> order(query, reporter, file -> upload(file, "订单记录.xlsx"))); + } + + /** 同步订单接口也分批生成完整附件。 */ + public void order(HttpServletResponse response, HardOrderDto dto) throws Exception { + order(dto, null, file -> BatchExportSupport.respond(file, "订单记录.xlsx", response)); + } + + private TResult order(HardOrderDto query, AsyncTaskProgressReporter reporter, BatchExcelExportTemplate.FileConsumer consumer) throws Exception { + orderQuerySupport.prepare(query); + Map values = BatchExportSupport.timeValues(query.getPayTimeList()); + BigDecimal[] sums = sums(3); + return BatchExportSupport.template("file/订单交易汇总.xlsx", values, + current -> orders.scopePageList(BatchExportSupport.page(current), query, DataScope.of()), + row -> orderRow(BeanUtils.toBean(row, HardOrderExcelVo.class), values, sums), reporter, consumer, workbook -> { }); + } + + /** 提交商户普通或按月导出,任务配置在提交时固定。 */ + public AsyncTaskProgressVO submitMerchant(HardOrderDto dto, boolean monthly) { + HardOrderDto query = BeanUtil.toBean(dto, HardOrderDto.class); + String name = java.time.LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH时mm分ss秒 ")) + (monthly ? "商户交易记录(按月).xlsx" : "商户交易记录.xlsx"); + return tasks.submitWithResult(monthly ? "hard:export:merchant-month" : "hard:export:merchant", "商户交易导出", 1, + reporter -> merchant(query, monthly, reporter, file -> upload(file, name))); + } + + /** 保留商户两种同步附件入口。 */ + public void merchant(HttpServletResponse response, HardOrderDto dto, boolean monthly) throws Exception { + String name = java.time.LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH时mm分ss秒 ")) + (monthly ? "商户交易记录(按月).xlsx" : "商户交易记录.xlsx"); + merchant(dto, monthly, null, file -> BatchExportSupport.respond(file, name, response)); + } + + private TResult merchant(HardOrderDto query, boolean monthly, AsyncTaskProgressReporter reporter, + BatchExcelExportTemplate.FileConsumer consumer) throws Exception { + prepareMerchant(query); + if (monthly) return monthly(query, reporter, consumer); + List rows = totals.listCsForExport(query, reporter); + if (reporter != null) reporter.advance("商户汇总完成,正在写入模板"); + Map values = BatchExportSupport.timeValues(query.getPayTimeList()); + BigDecimal[] sums = sums(7); + return BatchExportSupport.template("file/商户交易金额汇总.xlsx", values, pages(rows), + row -> merchantRow(row, values, sums), null, file -> { + if (reporter != null) reporter.advance("文件已生成,正在保存导出结果"); + return consumer.consume(file); + }, workbook -> { }); + } + + /** 沿用原导出的当前商户与下级商户展开,远程失败时终止。 */ + private void prepareMerchant(HardOrderDto dto) { + dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes()); + if (dto.getMerchantId() == null) return; + R> result = mchService.getByParentId(dto.getMerchantId()); + if (result == null || !result.isOk()) throw new AsyncTaskException("查询导出商户范围失败,请重试"); + if (result.getData() != null && !result.getData().isEmpty()) { + List ids = new ArrayList<>(); + ids.add(dto.getMerchantId()); + result.getData().forEach(row -> ids.add(row.getId())); + dto.setMerchantId(null); + dto.setMchIds(ids); + } + } + + /** 提交部门交易导出,当前历史部门快照归属规则不变。 */ + public AsyncTaskProgressVO submitDepartment(HardDeptTotalDto dto) { + HardDeptTotalDto query = BeanUtil.toBean(dto, HardDeptTotalDto.class); + return tasks.submitWithResult("hard:export:department", "部门交易记录导出", 1, + reporter -> department(query, reporter, file -> upload(file, "部门交易记录导出.xlsx"))); + } + + public void department(HttpServletResponse response, HardDeptTotalDto dto) throws Exception { + department(dto, null, file -> BatchExportSupport.respond(file, "部门交易记录导出.xlsx", response)); + } + + private TResult department(HardDeptTotalDto query, AsyncTaskProgressReporter reporter, + BatchExcelExportTemplate.FileConsumer consumer) throws Exception { + List rows = totals.listDeptForExport(query, reporter); + if (reporter != null) reporter.advance("部门统计完成,正在写入模板"); + Map values = BatchExportSupport.timeValues(query.getCreateTimeList()); + BigDecimal[] sums = sums(16); + return BatchExportSupport.template("file/部门交易金额汇总.xlsx", values, pages(rows), + row -> departmentRow(row, values, sums), null, file -> { + if (reporter != null) reporter.advance("文件已生成,正在保存导出结果"); + return consumer.consume(file); + }, workbook -> { }); + } + + /** 按原有年度、商户筛选取商户编号,再以每五家为一组读取月份数据。 */ + private TResult monthly(HardOrderDto query, AsyncTaskProgressReporter reporter, + BatchExcelExportTemplate.FileConsumer consumer) throws Exception { + int year; + try { year = Integer.parseInt(query.getPayTimeYearStr()); } + catch (Exception exception) { throw new AsyncTaskException("请选择导出年份"); } + HardSelectOrderMerchIdsDto selection = new HardSelectOrderMerchIdsDto(); + if (query.getMerchantId() != null) selection.setMerchIds(List.of(query.getMerchantId())); + selection.setMerchTypes(query.getMchTypeCodes()); + selection.setCreateTimeList(query.getCreateTimeList()); + selection.setPayTimeYearStr(query.getPayTimeYearStr()); + List ids = mapper.selectOrderMerchIds(selection); + if (ids.isEmpty()) throw new AsyncTaskException("当前筛选条件没有可导出的商户交易"); + if (reporter != null) reporter.updateTotal(BatchExcelExportTemplate.calculateProgressTotal(ids.size(), 5, 2), "正在按商户分批汇总月份"); + String merchantName = "所有"; + if (query.getMerchantId() != null) { + R result = mchService.getById(query.getMerchantId()); + if (result == null || !result.isOk()) throw new AsyncTaskException("查询商户名称失败,请重试"); + if (result.getData() != null) merchantName = result.getData().getName(); + } + Map values = new HashMap<>(); + values.put("title", year + "年" + merchantName + "合作商家流水情况统计表"); + List monthTotals = new ArrayList<>(); + for (int month = 1; month <= 12; month++) { + HardOrderCsVo.Month item = new HardOrderCsVo.Month(); + item.setDay(String.format("%04d-%02d", year, month)); + item.setTotalProfitPrice(0); item.setTotalProfitCount(0); + monthTotals.add(item); + } + Function>> fetcher = current -> { + int start = Math.toIntExact((current - 1) * BatchExportSupport.BATCH_SIZE); + int end = Math.min(start + BatchExportSupport.BATCH_SIZE, ids.size()); + List> rows = new ArrayList<>(); + for (int from = start; from < end; from += 5) { + List batch = ids.subList(from, Math.min(from + 5, end)); + HardOrderDto slice = BeanUtil.toBean(query, HardOrderDto.class); + slice.setMchIds(new ArrayList<>(batch)); + Page page = totals.pageListCsByMonth(new Page<>(1, 200), slice); + R> result = mchService.getByIds(batch); + if (result == null || !result.isOk()) throw new AsyncTaskException("查询商户名称失败,请重试"); + Map names = new HashMap<>(); + if (result.getData() != null) result.getData().forEach(item -> names.put(item.getId(), item.getName())); + for (Long id : batch) { + Map row = new LinkedHashMap<>(); + row.put("col0", names.getOrDefault(id, "未知商户")); + List> 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> 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 Function> pages(List rows) { + return current -> { + int start = Math.toIntExact((current - 1) * BatchExportSupport.BATCH_SIZE); + int end = Math.min(start + BatchExportSupport.BATCH_SIZE, rows.size()); + Page 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 orderRow(HardOrderExcelVo v, Map 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 merchantRow(HardOrderCsVo v, Map 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 departmentRow(HardDeptTotalVo v, Map 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); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalServiceImpl.java index c615e9519..cb78e49b3 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalServiceImpl.java @@ -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 listCs(HardOrderDto dto) { + return listCsInternal(dto, false, null); + } + + /** 导出按自然日分片,所有日期完成后复用原退款合并、部门展示与平台抽成。 */ + public List listCsForExport(HardOrderDto dto, AsyncTaskProgressReporter reporter) { + return listCsInternal(dto, true, reporter); + } + + private List 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 totalList = hardOrderMapper.listCs(totalDto, totalDataScope); + List 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 refundList = hardOrderMapper.listRefundCs(refundDto, refundDataScope); + List refundList = exporting ? queryCsExportSlices(refundDto, true, refundDataScope, reporter) : hardOrderMapper.listRefundCs(refundDto, refundDataScope); // 有退款没有流水的情况 if (ObjectUtil.isAllNotEmpty(totalList, refundList)) { List totalIds = totalList.stream().map(HardOrderCsVo::getMerchantId).toList(); @@ -534,6 +548,15 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { */ @Override public List pageListDept(HardDeptTotalDto dto) { + return departmentRows(dto, false, null); + } + + /** 导出专用历史部门归集,原始订单最多在内存保留一个批次。 */ + public List listDeptForExport(HardDeptTotalDto dto, AsyncTaskProgressReporter reporter) { + return departmentRows(dto, true, reporter); + } + + private List departmentRows(HardDeptTotalDto dto, boolean exporting, AsyncTaskProgressReporter reporter) { List deptList = queryDepartmentList(dto); if (ObjectUtil.isEmpty(deptList)) { return List.of(); @@ -552,14 +575,16 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { List walletRefundList = queryWalletStatistics( buildWalletStatisticsDto(dto, List.of(WalletBizEnum.REFUND.getCode()), null)); - List payDetails = - hardOrderMapper.listPayStatisticsDetails(buildPayStatisticsDto(dto)); - Map> detailsByBizType = new LinkedHashMap<>(); - detailsByBizType.put(WalletBizEnum.PAYMENT.getCode(), payDetails); - Map> transactionSnapshotMaps = - queryTransactionDepartmentSnapshotMaps(detailsByBizType); - List payList = aggregatePaymentsByTransactionDepartment(payDetails, - transactionSnapshotMaps.getOrDefault(WalletBizEnum.PAYMENT.getCode(), Map.of()), dto); + List payList; + if (exporting) { + payList = queryExportPayments(dto, reporter); + } else { + List payDetails = hardOrderMapper.listPayStatisticsDetails(buildPayStatisticsDto(dto)); + Map> groups = new LinkedHashMap<>(); + groups.put(WalletBizEnum.PAYMENT.getCode(), payDetails); + Map> snapshots = queryTransactionDepartmentSnapshotMaps(groups); + payList = aggregatePaymentsByTransactionDepartment(payDetails, snapshots.getOrDefault(WalletBizEnum.PAYMENT.getCode(), Map.of()), dto); + } Map walletBatchMap = indexWalletStatistics(walletBatchList); Map walletReduceMap = indexWalletStatistics(walletReduceList); @@ -1222,4 +1247,72 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { private Map getDeptMapByIds(List deptIds) { return RemoteBatchQueryUtil.queryMapByIds(deptIds, ids -> remoteDeptService.getDeptByDeptIds(ids).getData()); } + + /** 同一订单批次补齐钱包历史部门后立即累加,不保留全部订单及流水快照。 */ + private List queryExportPayments(HardDeptTotalDto dto, AsyncTaskProgressReporter reporter) { + Map aggregate = new LinkedHashMap<>(); + HardOrderSumAndCountDto query = buildPayStatisticsDto(dto); + long processed = 0; + long expected = 0; + for (long current = 1; ; current++) { + Page 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 details = page.getRecords(); + if (details.isEmpty()) break; + processed += details.size(); + Map> groups = new LinkedHashMap<>(); + groups.put(WalletBizEnum.PAYMENT.getCode(), details); + Map 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 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 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 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()); + } + } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/third/HardThirdOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/third/HardThirdOrderServiceImpl.java new file mode 100644 index 000000000..4027f4086 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/third/HardThirdOrderServiceImpl.java @@ -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 page(Page page, HardOrderDto dto, PjcloudUser user) { + prepareQuery(dto, user); + return orderService.scopePageList(page, dto, scope()); + } + + @Override + public List 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 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 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 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 refunds(Long orderId) { + return afterSaleService.list(new LambdaQueryWrapper() + .eq(HardAfterSaleEntity::getOrderId, orderId) + .eq(HardAfterSaleEntity::getType, HardSaleTypeEnum.AFTER_SALE.getType()) + .orderByDesc(HardAfterSaleEntity::getCreateTime).orderByDesc(HardAfterSaleEntity::getId)); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/third/HardThirdPayServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/third/HardThirdPayServiceImpl.java index 3fe30e23c..297f6cac8 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/third/HardThirdPayServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/third/HardThirdPayServiceImpl.java @@ -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() - .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> waterList(WaterDto dto, ThirdPaySoureBizTypeEnum soureType) { + PayBaseVo> 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>(); 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; diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/HardOrderQuerySupport.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/HardOrderQuerySupport.java new file mode 100644 index 000000000..9594660e1 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/HardOrderQuerySupport.java @@ -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 ids = dto.getMchIds(); + if (ObjectUtil.isNotEmpty(ids) && ids.size() == 1) { + var children = mchService.getByParentId(ids.get(0)).getData(); + if (ObjectUtil.isNotEmpty(children)) { + List expanded = new ArrayList<>(ids); + children.forEach(child -> expanded.add(child.getId())); + dto.setMchIds(expanded.stream().distinct().toList()); + } + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/HardOrderStatisticsScopeResolver.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/HardOrderStatisticsScopeResolver.java new file mode 100644 index 000000000..431e02acd --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/HardOrderStatisticsScopeResolver.java @@ -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 departments = scope.getDeptList().stream().distinct().toList(); + return new HardOrderStatisticsScope(tenantId, all, departments, + StrUtil.isBlank(scope.getUsername()) ? null : scope.getUsername()); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/HardOrderStatisticsService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/HardOrderStatisticsService.java new file mode 100644 index 000000000..b35c2088e --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/HardOrderStatisticsService.java @@ -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 daily = new TreeMap<>(); + Map 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 execute(Supplier 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; + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml index f125638d4..2f08b4ab6 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml @@ -262,12 +262,12 @@ 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' diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml index d0fbe0514..b6174f003 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml @@ -92,6 +92,29 @@ a.del_flag = '0' + + and a.id = #{dto.id} + + + and a.id in + + #{v} + + + + + and coalesce(a.refund_price, 0) = 0 and coalesce(a.refund_status, '0') != '10' + + + and a.refund_price > 0 and a.refund_price < a.pay_price + + + and a.refund_price > 0 and a.refund_price >= a.pay_price + + + and a.refund_status = '10' + + and a.hard_id = #{dto.hardId} @@ -217,6 +240,48 @@ + + + + + + and a.extra_price > 0 + and (a.extra_price is null or a.extra_price <= 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' + + and hard_filter.type = #{dto.hardTypeCode} + + + and hard_filter.type in + #{v} + + ) + + + 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} + ) + + + 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' + and wallet_filter.wallet_type_id = #{dto.walletTypeId} + + and wallet_filter.wallet_type_id in + #{v} + + ) + + + select a.id, a.order_no, @@ -290,28 +355,7 @@ and latest_sale.del_flag = '0' ) - - - and m.type = #{dto.hardTypeCode} - - - and m.type in - - #{v} - - - - and s.no = #{dto.afterSaleNo} - - - and w_m.wallet_type_id = #{dto.walletTypeId} - - - and w_m.wallet_type_id in - - #{v} - - + order by a.create_time desc, a.user_id desc, a.id desc @@ -428,9 +472,9 @@ 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 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 @@ 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 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 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 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 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, diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderStatisticsMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderStatisticsMapper.xml new file mode 100644 index 000000000..3e05dd572 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderStatisticsMapper.xml @@ -0,0 +1,67 @@ + + + + + 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 + + + + + and a.tenant_id = #{scope.tenantId} + + + + + and ( + + a.create_by = #{scope.username} + + or + + a.merchant_dept_id in + + #{department} + + + ) + + and 1 = 0 + + + + + + + + + + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/AsyncExportTaskTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/AsyncExportTaskTest.java new file mode 100644 index 000000000..547792b29 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/AsyncExportTaskTest.java @@ -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 redis = mock(RedisTemplate.class); + ValueOperations values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + Map 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 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 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; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/BatchExportTemplateTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/BatchExportTemplateTest.java new file mode 100644 index 000000000..aeadad6e6 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/BatchExportTemplateTest.java @@ -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> rows = rows(template, 201); + Map 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> rows = rows(template, 201); + Map 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.>of(); }, + row -> row, null, file -> { consumed.set(true); return null; }, workbook -> { })); + assertFalse(consumed.get()); + } + + @Test + void uploadFailureRemovesCompleteTemporaryWorkbook() throws Exception { + Path template = template("个人消费明细.xlsx"); + AtomicReference 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> 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 List slice(List 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> rows(Path template, int count) throws Exception { + Set 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> rows = new ArrayList<>(); + for (int i = 0; i < count; i++) { + Map 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 values(Path template) throws Exception { + Map 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 + " 字号"); + } + } + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/HardBatchExportFileTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/HardBatchExportFileTest.java new file mode 100644 index 000000000..7a5ba9ba9 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/HardBatchExportFileTest.java @@ -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 request = call.getArgument(0); + List 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(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())); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/HardBatchExportQueryTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/HardBatchExportQueryTest.java new file mode 100644 index 000000000..b905e73aa --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/export/HardBatchExportQueryTest.java @@ -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 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 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 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()); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/third/HardThirdOrderQuerySqlTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/third/HardThirdOrderQuerySqlTest.java new file mode 100644 index 000000000..4a8f76745 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/third/HardThirdOrderQuerySqlTest.java @@ -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(); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/third/HardThirdOrderServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/third/HardThirdOrderServiceImplTest.java new file mode 100644 index 000000000..11eab64a5 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/third/HardThirdOrderServiceImplTest.java @@ -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.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 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 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 captured = ArgumentCaptor.forClass(HardOrderDto.class); + ArgumentCaptor 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 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(); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/third/HardThirdPayQueryTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/third/HardThirdPayQueryTest.java new file mode 100644 index 000000000..62144b071 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/third/HardThirdPayQueryTest.java @@ -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 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()); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/third/HardThirdPaySourceTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/third/HardThirdPaySourceTest.java new file mode 100644 index 000000000..f429ff4a7 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/third/HardThirdPaySourceTest.java @@ -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 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 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 = """ + + + + + """; + 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.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 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); + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderStatisticsControllerTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderStatisticsControllerTest.java new file mode 100644 index 000000000..26854486e --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderStatisticsControllerTest.java @@ -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)); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderStatisticsScopeTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderStatisticsScopeTest.java new file mode 100644 index 000000000..f3e59a846 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderStatisticsScopeTest.java @@ -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()); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderStatisticsServiceTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderStatisticsServiceTest.java new file mode 100644 index 000000000..902f38934 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderStatisticsServiceTest.java @@ -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()); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderStatisticsSqlTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderStatisticsSqlTest.java new file mode 100644 index 000000000..b2c7198b9 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderStatisticsSqlTest.java @@ -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 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> 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.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 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 rows, java.util.function.Function 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 nullable(Object... pairs) { + Map 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 overrides) throws Exception { + Map 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(); + } + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/ThirdKeyTypeEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/ThirdKeyTypeEnum.java index a58fbcbc6..e19c24de3 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/ThirdKeyTypeEnum.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/ThirdKeyTypeEnum.java @@ -15,6 +15,7 @@ import java.util.Arrays; public enum ThirdKeyTypeEnum { LB("10", "宁波银行"), + NFNCP("20", "农发农产品"), ; private final String type; diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPaySoureBizTypeEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPaySoureBizTypeEnum.java index e01daa92c..29495c07c 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPaySoureBizTypeEnum.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPaySoureBizTypeEnum.java @@ -14,6 +14,7 @@ import java.util.Arrays; public enum ThirdPaySoureBizTypeEnum { LB("10", "宁波银行"), + NFNCP("20", "农发农产品"), ; private final String type; diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdPayLogDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdPayLogDto.java index ea1982343..92ab16220 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdPayLogDto.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdPayLogDto.java @@ -10,4 +10,9 @@ public class ThirdPayLogDto extends ThirdPayLogEntity { private LocalDate[] createTimeList; + /** 按 JSON 字段精确关联底单,避免请求全文模糊匹配到其他订单。 */ + private String relatedPlatformOrderId; + + private String relatedThirdOrderId; + } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayLogController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayLogController.java index 74c38aa30..553bac8b4 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayLogController.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayLogController.java @@ -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 wrapper = new LambdaQueryWrapper() - .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 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 export(ThirdPayLogEntity thirdPayLog, Long[] ids) { - return thirdPayLogService.list(Wrappers.lambdaQuery(thirdPayLog).in(ArrayUtil.isNotEmpty(ids), ThirdPayLogEntity::getId, ids)); + public List export(ThirdPayLogDto dto, Long[] ids) { + return thirdPayLogService.exportList(dto, ids); } -} \ No newline at end of file +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayLogService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayLogService.java index 80ea9286e..cdde487dc 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayLogService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayLogService.java @@ -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 { -} \ No newline at end of file + Page pageList(Page page, ThirdPayLogDto dto); + + List exportList(ThirdPayLogDto dto, Long[] ids); + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayApiServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayApiServiceImpl.java index ad28adc01..97e65d065 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayApiServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayApiServiceImpl.java @@ -56,7 +56,7 @@ public class ThirdPayApiServiceImpl implements ThirdPayApiService { @Override public PayBaseVo walletPay(String json, Map 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(); ThirdKeyManageEntity keySetting = keyManageService.checkSignAndReturnConfig(json, headers); @@ -108,7 +108,7 @@ public class ThirdPayApiServiceImpl implements ThirdPayApiService { @Override public PayBaseVo walletRefund(String json, Map 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(); ThirdKeyManageEntity keySetting = keyManageService.checkSignAndReturnConfig(json, headers); @@ -161,7 +161,7 @@ public class ThirdPayApiServiceImpl implements ThirdPayApiService { public PayBaseVo getWalletInfo(String json, Map 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(); ThirdKeyManageEntity keySetting = keyManageService.checkSignAndReturnConfig(json, headers); @@ -240,7 +240,7 @@ public class ThirdPayApiServiceImpl implements ThirdPayApiService { public PayBaseVo> water(String json, Map 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(); 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 detail(String json, Map 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(); 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 headers) { + if (ObjectUtil.isEmpty(headers)) { + return ""; + } + String paySourceType = ""; + for (Map.Entry entry : headers.entrySet()) { + if ("BIZ-TYPE".equalsIgnoreCase(entry.getKey())) { + paySourceType = entry.getValue(); + } + } + return paySourceType == null ? "" : paySourceType; + } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayLogServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayLogServiceImpl.java index 30ad6a876..bb51c8a8d 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayLogServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayLogServiceImpl.java @@ -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 implements ThirdPayLogService { -} \ No newline at end of file + @Override + public Page pageList(Page page, ThirdPayLogDto dto) { + // 先分页查询主键,避免携带 req/res/header 等大文本执行分页排序。 + baseMapper.selectPage(page, buildQuery(dto).select(ThirdPayLogEntity::getId)); + if (page.getRecords().isEmpty()) { + return page; + } + List ids = page.getRecords().stream().map(ThirdPayLogEntity::getId).toList(); + Map 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 exportList(ThirdPayLogDto dto, Long[] ids) { + return baseMapper.selectList(buildQuery(dto) + .in(ArrayUtil.isNotEmpty(ids), ThirdPayLogEntity::getId, (Object[]) ids)); + } + + private LambdaQueryWrapper buildQuery(ThirdPayLogDto dto) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .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 + "'))"; + } + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/doc/第三方支付文档.wps b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/doc/第三方支付文档.wps index 1b0f1b492..505b32e89 100644 Binary files a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/doc/第三方支付文档.wps and b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/doc/第三方支付文档.wps differ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/admin/PayTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/admin/PayTest.java index c45d59601..c437fedfc 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/admin/PayTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/admin/PayTest.java @@ -119,14 +119,16 @@ public class PayTest { log.info(res); } + // 退款 @Test public void walletRefund() throws Exception { ThirdKeyManageEntity config = getLbConfig(); Map 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(); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayLogOrderAssociationTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayLogOrderAssociationTest.java new file mode 100644 index 000000000..e12fc4658 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayLogOrderAssociationTest.java @@ -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 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")); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayWaterSourceTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayWaterSourceTest.java new file mode 100644 index 000000000..6f58ee180 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayWaterSourceTest.java @@ -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 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> response = new PayBaseVo<>(); + response.setCode(200); + response.setData(List.of(item)); + when(fixture.hardware.waterList(any(), eq(source))).thenReturn(response); + Map headers = fixture.headers(); + headers.put(headerName, source.getType()); + assertEquals(200, fixture.service.water(JSON, headers).getCode()); + ArgumentCaptor request = ArgumentCaptor.forClass(WaterDto.class); + verify(fixture.hardware).waterList(request.capture(), eq(source)); + assertEquals(2L, request.getValue().getCurrent()); + assertEquals(200L, request.getValue().getSize()); + ArgumentCaptor 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 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 headers() { + Map 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; + } + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/pom.xml b/pjcloud-upms/pjcloud-upms-biz/pom.xml index 4ec10d336..b49e28c8f 100644 --- a/pjcloud-upms/pjcloud-upms-biz/pom.xml +++ b/pjcloud-upms/pjcloud-upms-biz/pom.xml @@ -33,6 +33,11 @@ pjcloud 通用用户权限管理系统业务处理模块 + + + com.bsd + pjcloud-common-async-task + com.qiniu qiniu-java-sdk diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java index 42f9a4f62..7a94a8dc3 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java @@ -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 exportAsync(@RequestBody UserDTO userDTO) { + return R.ok(batchExportService.submit(userDTO), "用户导出任务已提交"); } /** diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserMapper.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserMapper.java index 23c7e8505..c02ef6713 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserMapper.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserMapper.java @@ -76,6 +76,9 @@ public interface SysUserMapper extends PjcloudBaseMapper { * @param dataScope 数据权限声明 * @return */ + /** 仅供导出使用的分页,查询条件与原完整导出一致,主键用于稳定排序。 */ + Page selectVoExportPageByScope(Page page, @Param("query") UserDTO userDTO, @Param("ids") Long[] ids, DataScope dataScope); + List selectVoListByScope(@Param("query") UserDTO userDTO, @Param("ids") Long[] ids, DataScope dataScope); /** diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserBatchExportService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserBatchExportService.java new file mode 100644 index 000000000..e4617055e --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserBatchExportService.java @@ -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 ASYNC_EXCLUDED_FIELDS = Set.of("postNameList", "staffingTypeParse", "lockFlag"); + + /** 固定列宽,不扫描全部用户;可用全局 token 按字段覆盖。 */ + private static final Map 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 generate(UserDTO query, AsyncTaskProgressReporter reporter, ExcelExportStyleHandler style, + Set excludedFields, + BatchExcelExportTemplate.FileConsumer consumer) throws Exception { + Page 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 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 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); + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml index def99ddc4..bae556761 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml @@ -319,6 +319,83 @@ + + + + SELECT a.user_id, a.balance, a.wallet_id, @@ -72,9 +72,18 @@ + + + + + select a.user_id from wallet_manage a @@ -301,4 +310,4 @@ - \ No newline at end of file + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletTransactionMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletTransactionMapper.xml index d60bb94c6..7bc37f605 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletTransactionMapper.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletTransactionMapper.xml @@ -356,7 +356,7 @@ - order by a.create_time desc, a.user_id desc + order by a.create_time desc, a.user_id desc, a.id desc \ No newline at end of file diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeApplyMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeApplyMapper.xml index 3a71db167..b99284a84 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeApplyMapper.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeApplyMapper.xml @@ -90,7 +90,8 @@ ELSE 0 END ASC, a.apply_time DESC, - a.create_time DESC + a.create_time DESC, + a.id DESC diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeImportMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeImportMapper.xml index 92fbfbb38..8be79ebbc 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeImportMapper.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeImportMapper.xml @@ -101,7 +101,7 @@ and p.process_time between #{dto.processTimeList[0]} and #{dto.processTimeList[1]} - order by a.create_time desc + order by a.create_time desc, a.id desc