feat:订单导出优化

feat:分账统计模块
This commit is contained in:
dzc committed 2026-08-26 15:44:15 +08:00
1 parent 14c2acb6c2
commit 4bf5930e64
22 files changed
+1892 -392

No files matched your search

@@ -14,3 +14,5 @@ WHERE ((`order_status` = '20' AND `order_type` IN ('1', '2', '3', '4'))
OR `pay_type` IN ('5', '6', '50'))
AND `verification_time` IS NULL
AND `update_time` IS NOT NULL;
@@ -38,6 +38,9 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
private List<String> mchTypeCodes;
/** 页面选择的商户类型编码集合;与服务端注入的商户类型权限范围同时生效。 */
private List<String> merchantTypeCodes;
private List<String> orderStatusCodes;
private LocalDateTime[] createTimeList;
@@ -82,6 +85,9 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
private List<String> hardTypeCodes;
/** 用餐时间段字典编码集合;为空表示不限制餐段。 */
private List<String> hourTypeCodes;
private List<String> refundStatusCodes;
/**
@@ -0,0 +1,166 @@
package com.bsd.pjcloud.hard.api.vo.order.excel;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.format.NumberFormat;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.alibaba.excel.annotation.write.style.ContentFontStyle;
import com.alibaba.excel.annotation.write.style.HeadFontStyle;
import com.alibaba.excel.annotation.write.style.HeadRowHeight;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* 硬件订单明细导出行,按订单业务域组织二级表头。
*
* <p>仅保留业务核对所需的用户、身份、订单、支付、核销和退款信息,隐藏内部主键及审计字段;
* 金额字段统一使用元。该对象仅用于导出展示,不参与数据库持久化和接口入参。</p>
*/
@Data
@ColumnWidth(15)
@HeadRowHeight(15)
@HeadFontStyle(fontHeightInPoints = 10)
@ContentFontStyle(fontHeightInPoints = 10)
public class HardOrderExportExcelVo {
/** 当前订单在本次导出结果中的一基序号;合计行为空。 */
@ExcelProperty({"订单", "序号"})
private String lineNumber;
/** 订单类型中文名称;未知历史编码保留原始值。 */
@ExcelProperty({"订单", "订单类型"})
private String orderType;
/** 订单状态中文名称;未知历史编码保留原始值。 */
@ExcelProperty({"订单", "订单状态"})
private String orderStatus;
/** 统计场景中的订单来源名称;不属于统计来源时为空。 */
@ExcelProperty({"订单", "订单来源"})
private String saleSourceName;
/** 订单创建时间,格式为 yyyy-MM-dd HH:mm:ss。 */
@ExcelProperty({"订单", "下单时间"})
@ColumnWidth(22)
private String createTime;
/** 用户姓名,优先取 UPMS 姓名,其次取昵称、账号和订单历史展示名。 */
@ExcelProperty({"用户", "姓名"})
private String userName;
/** 用户默认身份手机号;UPMS 资料缺失时回退订单历史手机号。 */
@ExcelProperty({"用户", "手机号"})
private String phone;
/** 用户当前所属部门名称;UPMS 资料缺失时回退订单历史部门名称。 */
@ExcelProperty({"用户", "部门"})
private String deptName;
/** 用户岗位名称,多个岗位使用中文逗号分隔。 */
@ExcelProperty({"用户", "岗位"})
private String postNames;
/** 用户拥有的身份中文名称,多个身份使用中文逗号分隔。 */
@ExcelProperty({"身份", "身份类型"})
private String identityTypeNames;
/** 学生身份学号;没有学生身份时为空。 */
@ExcelProperty({"身份", "学号"})
private String studentNo;
/** 学生身份学校名称;没有学生身份时为空。 */
@ExcelProperty({"身份", "学校"})
private String schoolName;
/** 学生身份班级名称;没有学生身份时为空。 */
@ExcelProperty({"身份", "班级"})
private String className;
/** 学生身份监护人手机号,多个号码沿用业务存储格式。 */
@ExcelProperty({"身份", "监护人手机号"})
private String guardianPhones;
/** 老师身份工号或学号;没有老师身份时为空。 */
@ExcelProperty({"身份", "老师学号"})
private String teacherNo;
/** 钱包服务返回的商户名称;历史商户不存在时回退订单查询结果。 */
@ExcelProperty({"商户设备", "商户"})
private String merchantName;
/** 订单关联设备名称;预约核销订单中用于识别核销机器。 */
@ExcelProperty({"商户设备", "核销机器"})
private String hardName;
/** 实际关联设备的设备类型中文名称;未知历史编码保留原始值。 */
@ExcelProperty({"商户设备", "设备类型"})
private String hardType;
/** 订单商品摘要,来源于下单时保存的商品描述。 */
@ExcelProperty({"订单", "商品内容"})
@ColumnWidth(24)
private String productDesc;
/** 用餐时间段中文名称;未知历史编码保留原始值。 */
@ExcelProperty({"订单", "用餐时段"})
private String hourType;
/** 预约目标用餐日期;非预约订单可能为下单日期或空值,页面统一称为预约时间。 */
@ExcelProperty({"订单", "预约时间"})
@ColumnWidth(22)
private LocalDate apptUseDate;
/** 支付方式中文名称;未知历史编码保留原始值。 */
@ExcelProperty({"支付", "支付方式"})
private String payType;
/** 商品总价,单位为元并保留两位小数。 */
@ExcelProperty({"支付", "商品总价(元)"})
@NumberFormat("#,##0.00")
private BigDecimal totalPrice;
/** 额外费用,单位为元并保留两位小数。 */
@ExcelProperty({"支付", "额外支付(元)"})
@NumberFormat("#,##0.00")
private BigDecimal extraPrice;
/** 配送费用,单位为元并保留两位小数。 */
@ExcelProperty({"支付", "配送费(元)"})
@NumberFormat("#,##0.00")
private BigDecimal deliveryPrice;
/** 实际支付金额,单位为元并保留两位小数。 */
@ExcelProperty({"支付", "实际支付(元)"})
@NumberFormat("#,##0.00")
private BigDecimal payPrice;
/** 订单实际金额,单位为元,按实际支付金额扣除退款金额计算。 */
@ExcelProperty({"支付", "订单实际(元)"})
@NumberFormat("#,##0.00")
private BigDecimal orderActualPrice;
/** 支付完成时间,格式为 yyyy-MM-dd HH:mm:ss;未支付时为空。 */
@ExcelProperty({"支付", "支付时间"})
@ColumnWidth(22)
private String payTime;
/** 订单实际核销完成时间,格式为 yyyy-MM-dd HH:mm:ss。 */
@ExcelProperty({"核销", "核销时间"})
@ColumnWidth(22)
private String verificationTime;
/** 订单退款状态中文名称;未记录时为空,未知历史编码保留原始值。 */
@ExcelProperty({"退款", "退款状态"})
private String refundStatus;
/** 已退款金额,单位为元并保留两位小数。 */
@ExcelProperty({"退款", "退款金额(元)"})
@NumberFormat("#,##0.00")
private BigDecimal refundPrice;
/** 退款完成时间,格式为 yyyy-MM-dd HH:mm:ss。 */
@ExcelProperty({"退款", "退款完成时间"})
@ColumnWidth(22)
private String refundTime;
}
@@ -86,7 +86,7 @@ public class HardOrderController {
private final HardOrderSyncService hardOrderSyncService;
private final HardCanteenTotalService canteenTotalService;
/** 订单导出服务,负责异步任务、分批模板写入和文件中心上传。 */
/** 订单导出服务,负责异步任务、EasyExcel 分片写入和文件中心上传。 */
private final HardOrderExportService hardOrderExportService;
private final HardOrderMapper hardOrderMapper;
@@ -7,7 +7,7 @@ import jakarta.servlet.http.HttpServletResponse;
/**
* 硬件订单导出服务。
*
* <p>该服务独立承担导出条件冻结、数据权限、分批查询、模板写入、进度上报和文件中心上传,
* <p>该服务独立承担导出条件冻结、数据权限、分批查询、自定义表头写入、进度上报和文件中心上传,
* 订单查询服务只保留订单业务查询职责。</p>
*/
public interface HardOrderExportService {
@@ -1,9 +1,9 @@
package com.bsd.pjcloud.hard.service.impl.order;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.io.resource.ResourceUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.constant.user.UserIdentityTypeEnum;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
@@ -22,11 +22,16 @@ import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum;
import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum;
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.HardRefundStatusEnum;
import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto;
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.HardOrderExportExcelVo;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.hard.service.HardOrderExportService;
@@ -36,13 +41,6 @@ import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.xssf.streaming.SXSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.beans.BeanUtils;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.stereotype.Service;
@@ -52,6 +50,7 @@ import java.math.BigDecimal;
import java.math.RoundingMode;
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.ArrayList;
@@ -65,139 +64,132 @@ import java.util.stream.Collectors;
* 硬件订单导出服务实现。
*
* <p>导出前冻结当前用户可见的设备类型、商户范围和身份筛选,后台任务按稳定顺序分页读取订单,
* 并在同一任务线程同步补充完整用户资料和商户名称。使用 SXSSF 保留原订单模板样式,
* 完成后上传文件中心并返回下载地址。</p>
* 并在同一任务线程同步补充完整用户资料和商户名称。使用 EasyExcel 自定义二级表头分片写入,
* 完成后上传文件中心并返回下载地址,不再依赖固定 XLSX 模板。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class HardOrderExportServiceImpl implements HardOrderExportService {
/** 订单导出日志标签,用于定位分批查询、模板写入和文件上传异常。 */
/**
* 订单导出日志标签,用于定位分批查询、工作簿写入和文件上传异常。
*/
private static final String LOG_TAG = "硬件订单导出";
/** 订单导出任务的 Redis Key 业务命名空间。 */
/**
* 订单导出任务的 Redis Key 业务命名空间。
*/
private static final String ORDER_EXPORT_REDIS_KEY = "hard:order:export";
/** 订单导出任务在进度弹窗中的展示名称。 */
/**
* 订单导出任务在进度弹窗中的展示名称。
*/
private static final String ORDER_EXPORT_TASK_NAME = "订单记录导出";
/** 后台取得真实订单数前创建任务使用的占位总数。 */
/**
* 后台取得真实订单数前创建任务使用的占位总数。
*/
private static final int ORDER_EXPORT_INITIAL_TOTAL = 1;
/** 单次订单查询和 SXSSF 内存窗口使用的记录数。 */
/**
* 单次订单查询和 EasyExcel 流式写入使用的记录数。
*/
private static final int ORDER_EXPORT_BATCH_SIZE = 200;
/** 单次查询完整用户导出资料的最大数量,当前订单分片不会超过该值。 */
/**
* 单次查询完整用户导出资料的最大数量,当前订单分片不会超过该值。
*/
private static final long USER_EXPORT_BATCH_SIZE = 500L;
/** 文件上传及异步任务成功收尾占用的额外进度步数。 */
/**
* 文件上传及异步任务成功收尾占用的额外进度步数。
*/
private static final int ORDER_EXPORT_EXTRA_PROGRESS_STEPS = 2;
/** 订单模板标题行的 Excel 零基行号。 */
private static final int ORDER_TITLE_ROW_INDEX = 0;
/** 订单模板表头行的 Excel 零基行号。 */
private static final int ORDER_HEADER_ROW_INDEX = 1;
/** 订单模板中明细占位行的 Excel 零基行号。 */
private static final int ORDER_DETAIL_TEMPLATE_ROW_INDEX = 2;
/** 订单模板中合计占位行的 Excel 零基行号。 */
private static final int ORDER_SUMMARY_TEMPLATE_ROW_INDEX = 3;
/** 订单明细从模板第三行开始写入。 */
private static final int ORDER_DETAIL_START_ROW_INDEX = 2;
/** 订单明细之后为合计保留一行。 */
private static final int ORDER_RESERVED_TAIL_ROW_COUNT = 1;
/** 既有订单模板在运行时扩展前固定包含的列数。 */
private static final int ORDER_ORIGINAL_TEMPLATE_COLUMN_COUNT = 12;
/**
* 订单导出的完整列标题,用户资料沿用用户预约导出的基础信息和身份字段口径。
* EasyExcel 二级表头占用的固定行数。
*/
private static final String[] ORDER_EXPORT_HEADERS = {
"行号", "商户", "用户编号", "姓名", "邮箱", "部门", "岗位", "手机号", "身份类型",
"学号", "学校名称", "班级名称", "监护人手机号", "老师学号", "商品", "支付方式",
"用餐时间段", "商品总价", "额外支付", "实际支付", "订单支付时间"
};
private static final int ORDER_EXPORT_HEADER_ROW_COUNT = 2;
/**
* 各导出列复用的原模板明细样式来源列,保留文本、金额和日期列的既有显示格式。
* 全部订单明细之后写入的金额合计行数量。
*/
private static final int[] ORDER_DETAIL_STYLE_SOURCE_COLUMNS = {
0, 1, 0, 2, 3, 4, 4, 3, 4, 4, 4, 4, 4, 4, 5, 6, 7, 8, 9, 10, 11
};
private static final int ORDER_EXPORT_SUMMARY_ROW_COUNT = 1;
/**
* 各导出列复用的原模板合计样式来源列,合并区末列单独保留右边框。
* 单个订单工作表最多允许写入的明细数量。
*/
private static final int[] ORDER_SUMMARY_STYLE_SOURCE_COLUMNS = {
0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 6, 8, 9, 10, 11
};
/** 各导出列宽度,单位为 Excel 字符宽度。 */
private static final int[] ORDER_EXPORT_COLUMN_WIDTHS = {
10, 20, 20, 16, 28, 20, 20, 18, 18, 18, 22, 18, 24, 18, 30, 18, 18, 16, 16, 16, 22
};
/** 订单模板扩展后的固定列数。 */
private static final int ORDER_EXPORT_COLUMN_COUNT = ORDER_EXPORT_HEADERS.length;
/** 商品总价在扩展模板中的 Excel 零基列号。 */
private static final int ORDER_TOTAL_PRICE_COLUMN_INDEX = 17;
/** 额外支付在扩展模板中的 Excel 零基列号。 */
private static final int ORDER_EXTRA_PRICE_COLUMN_INDEX = 18;
/** 实际支付在扩展模板中的 Excel 零基列号。 */
private static final int ORDER_PAY_PRICE_COLUMN_INDEX = 19;
/** 合计文本合并区域的最后一个 Excel 零基列号。 */
private static final int ORDER_SUMMARY_MERGE_LAST_COLUMN_INDEX = 16;
/** 单个订单工作表最多允许写入的明细数量。 */
private static final int MAX_ORDER_EXPORT_ROWS = BatchExcelExportTemplate.calculateMaxDetailRowCount(
ORDER_DETAIL_START_ROW_INDEX, ORDER_RESERVED_TAIL_ROW_COUNT);
ORDER_EXPORT_HEADER_ROW_COUNT, ORDER_EXPORT_SUMMARY_ROW_COUNT);
/** 订单导出使用的既有类路径模板。 */
private static final String ORDER_EXPORT_TEMPLATE_PATH = "file/订单交易汇总.xlsx";
/**
* 订单导出工作表名称。
*/
private static final String ORDER_EXPORT_SHEET_NAME = "订单记录";
/** 文件中心保存订单导出文件的业务目录。 */
/**
* 文件中心保存订单导出文件的业务目录。
*/
private static final String ORDER_EXPORT_FILE_DIR = "hard-export";
/** 文件中心区分订单导出文件的业务类型编码。 */
/**
* 文件中心区分订单导出文件的业务类型编码。
*/
private static final String ORDER_EXPORT_FILE_TYPE = "HARD_ORDER_EXPORT";
/** XLSX 文件的标准 MIME 类型。 */
/**
* XLSX 文件的标准 MIME 类型。
*/
private static final String ORDER_EXPORT_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
/** 异步订单导出文件名使用的时间格式。 */
/**
* 异步订单导出文件名使用的时间格式。
*/
private static final DateTimeFormatter ORDER_EXPORT_FILE_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
/** 订单 Mapper,负责在当前导出任务线程内按数据权限分页读取原始订单。 */
/**
* Excel 日期时间字段统一展示格式,避免 EasyExcel 依赖服务端默认格式。
*/
private static final DateTimeFormatter EXCEL_DATE_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* 订单 Mapper,负责在当前导出任务线程内按数据权限分页读取原始订单。
*/
private final HardOrderMapper hardOrderMapper;
/** 用户身份支持器,负责把身份筛选条件预转换为用户主键范围。 */
/**
* 用户身份支持器,负责把身份筛选条件预转换为用户主键范围。
*/
private final HardUserIdentitySupport userIdentitySupport;
/** 用户远程服务,负责在当前导出任务线程内批量查询完整基础资料和身份资料。 */
/**
* 用户远程服务,负责在当前导出任务线程内批量查询完整基础资料和身份资料。
*/
private final RemoteUserService remoteUserService;
/** 设备服务,用于冻结当前用户允许查看的设备类型。 */
/**
* 设备服务,用于冻结当前用户允许查看的设备类型。
*/
private final HardManagerService hardManagerService;
/** 商户远程服务,用于把单个商户筛选扩展为其现有子商户范围。 */
/**
* 商户远程服务,用于把单个商户筛选扩展为其现有子商户范围。
*/
private final RemoteMchService remoteMchService;
/** 文件中心远程服务,用于持久化异步生成的订单工作簿。 */
/**
* 文件中心远程服务,用于持久化异步生成的订单工作簿。
*/
private final RemoteFileService remoteFileService;
/** 通用异步任务执行器,负责恢复租户、安全和 Feign Token 上下文并持久化进度。 */
/**
* 通用异步任务执行器,负责恢复租户、安全和 Feign Token 上下文并持久化进度。
*/
private final AsyncTaskExecutorService asyncTaskExecutorService;
/**
@@ -220,7 +212,7 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
* 避免部分失败文件被浏览器当作成功结果下载。</p>
*
* @param response 当前 HTTP 下载响应
* @param dto 订单页面当前筛选条件
* @param dto 订单页面当前筛选条件
*/
@Override
public void export(HttpServletResponse response, HardOrderDto dto) {
@@ -238,7 +230,7 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
/**
* 在异步线程中生成订单工作簿并上传文件中心。
*
* @param query 已冻结权限和筛选条件的订单查询
* @param query 已冻结权限和筛选条件的订单查询
* @param reporter 当前异步任务进度报告器
* @return 文件中心下载地址和业务文件名
*/
@@ -264,56 +256,76 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
}
/**
* 使用公共模板按页查询订单、流式写入明细和合计,并消费最终 XLSX 临时文件。
* 按页查询订单并使用 EasyExcel 流式写入自定义二级表头工作簿。
*
* @param query 已冻结的订单查询条件
* @param firstPage 已用于取得总数的第一页,避免重复查询首批订单
* @param total 导出开始时按相同条件统计的订单总数
* @param reporter 异步任务进度报告器;同步兼容导出时为空
* <p>文件先写入临时路径,只有工作簿完整关闭后才交给上传或 HTTP 响应回调;每批写入后上报一次进度,
* 避免大批量订单长期占用请求线程和堆内存。</p>
*
* @param query 已冻结的订单查询条件
* @param firstPage 已用于取得总数的第一页,避免重复查询首批订单
* @param total 导出开始时按相同条件统计的订单总数
* @param reporter 异步任务进度报告器;同步兼容导出时为空
* @param fileConsumer 完整临时文件消费回调
* @param <R> 文件消费结果类型
* @param <R> 文件消费结果类型
* @return 文件消费回调返回的结果
*/
private <R> R writeExportWorkbook(HardOrderDto query, Page<OrderExportRow> firstPage, long total,
AsyncTaskProgressReporter reporter,
BatchExcelExportTemplate.FileConsumer<R> fileConsumer) {
Path outputPath = null;
try {
return BatchExcelExportTemplate.<OrderExportRow, OrderExportTemplateContext, R>builder(total)
.batchSize(ORDER_EXPORT_BATCH_SIZE)
.dataStartRowIndex(ORDER_DETAIL_START_ROW_INDEX)
.reservedTailRowCount(ORDER_RESERVED_TAIL_ROW_COUNT)
.temporaryFilePrefix("hard-order-export-")
.templateSupplier(() -> ResourceUtil.getStream(ORDER_EXPORT_TEMPLATE_PATH))
.templateInitializer(workbook -> prepareTemplate(workbook, query))
.pageFetcher((pageNumber, batchSize) -> pageNumber == 1L
? safeRecords(firstPage)
: safeRecords(fetchExportPage(pageNumber, batchSize, query, false)))
.rowWriter((sheet, rowIndex, dataIndex, item, context) -> {
context.summary.add(item);
writeDetailRow(sheet, rowIndex, dataIndex, item, context);
})
.footerWriter((sheet, rowIndex, processedTotal, context) ->
writeSummaryRow(sheet, rowIndex, context))
.progressListener((processedTotal, expectedTotal) -> {
if (reporter != null) {
reporter.advance("已写入 " + processedTotal + " / " + expectedTotal + " 条订单");
}
})
.fileConsumer(fileConsumer)
.execute();
outputPath = Files.createTempFile("hard-order-export-", ".xlsx");
OrderExportSummary summary = new OrderExportSummary();
long processedTotal = 0L;
long current = 1L;
try (ExcelWriter excelWriter = EasyExcel.write(outputPath.toFile(), HardOrderExportExcelVo.class).build()) {
WriteSheet writeSheet = EasyExcel.writerSheet(ORDER_EXPORT_SHEET_NAME).build();
Page<OrderExportRow> currentPage = firstPage;
while (processedTotal < total) {
List<OrderExportRow> records = safeRecords(currentPage);
if (records.isEmpty()) {
break;
}
long remaining = total - processedTotal;
int recordCount = Math.toIntExact(Math.min(records.size(), remaining));
List<OrderExportRow> batch = records.subList(0, recordCount);
List<HardOrderExportExcelVo> excelRows = new ArrayList<>(batch.size());
for (OrderExportRow item : batch) {
summary.add(item);
excelRows.add(toExcelRow(item, processedTotal + excelRows.size() + 1L));
}
excelWriter.write(excelRows, writeSheet);
processedTotal += batch.size();
if (reporter != null) {
reporter.advance("已写入 " + processedTotal + " / " + total + " 条订单");
}
if (processedTotal >= total) {
break;
}
currentPage = fetchExportPage(++current, ORDER_EXPORT_BATCH_SIZE, query, false);
}
if (processedTotal != total) {
throw new IllegalStateException("订单数据在导出期间发生变化,预期 " + total
+ " 条,实际处理 " + processedTotal + " 条");
}
excelWriter.write(List.of(toSummaryExcelRow(summary)), writeSheet);
}
return fileConsumer.consume(outputPath);
} catch (IllegalArgumentException | IllegalStateException exception) {
throw exception;
} catch (Exception exception) {
throw new IllegalStateException("生成订单导出文件失败", exception);
} finally {
deleteTemporaryExportFile(outputPath);
}
}
/**
* 查询指定订单导出分页,数据范围和列表页保持为商户部门权限。
*
* @param current 从一开始的页码
* @param size 单批最大订单数
* @param query 已冻结的订单筛选条件
* @param current 从一开始的页码
* @param size 单批最大订单数
* @param query 已冻结的订单筛选条件
* @param searchCount 是否执行总数查询,仅第一页需要
* @return 在当前线程同步装配完整用户资料和商户名称的导出分页
*/
@@ -340,7 +352,7 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
Map<Long, UserVO> userMap = fetchExportUserMap(userIds);
Map<Long, String> merchantNameMap = fetchMerchantNameMap(merchantIds);
return orders.stream().map(order -> new OrderExportRow(order, userMap.get(order.getUserId()),
StrUtil.blankToDefault(merchantNameMap.get(order.getMerchantId()), order.getMerchantName())))
StrUtil.blankToDefault(merchantNameMap.get(order.getMerchantId()), order.getMerchantName())))
.toList();
}
@@ -433,211 +445,86 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
}
/**
* 读取模板明细、合计样式并移除占位行,为 SXSSF 连续追加订单腾出空间。
* 将一条组合订单转换为二级表头导出对象。
*
* @param workbook 由既有订单模板创建的可编辑工作簿
* @param query 已冻结的订单筛选条件,用于生成标题时间范围
* @return 明细和合计写入共享的样式及金额汇总上下文
* @param item 当前订单及其完整用户、商户资料
* @param lineNumber 当前订单在全部导出结果中的一基序号
* @return 已完成枚举翻译、金额和时间格式化的精简导出行
*/
private OrderExportTemplateContext prepareTemplate(XSSFWorkbook workbook, HardOrderDto query) {
Sheet sheet = workbook.getSheetAt(0);
Row titleRow = sheet.getRow(ORDER_TITLE_ROW_INDEX);
Row headerRow = sheet.getRow(ORDER_HEADER_ROW_INDEX);
Row detailTemplate = sheet.getRow(ORDER_DETAIL_TEMPLATE_ROW_INDEX);
Row summaryTemplate = sheet.getRow(ORDER_SUMMARY_TEMPLATE_ROW_INDEX);
if (titleRow == null || headerRow == null || detailTemplate == null || summaryTemplate == null) {
throw new IllegalStateException("订单导出模板缺少标题、表头、明细或合计占位行");
}
extendTemplateColumns(sheet, titleRow, headerRow, detailTemplate, summaryTemplate);
CellStyle[] detailStyles = readRowStyles(detailTemplate);
CellStyle[] summaryStyles = readRowStyles(summaryTemplate);
short detailRowHeight = detailTemplate.getHeight();
short summaryRowHeight = summaryTemplate.getHeight();
titleRow.getCell(0).setCellValue(buildTitle(query));
for (int index = sheet.getNumMergedRegions() - 1; index >= 0; index--) {
CellRangeAddress mergedRegion = sheet.getMergedRegion(index);
if (mergedRegion.getFirstRow() == ORDER_TITLE_ROW_INDEX
|| mergedRegion.getFirstRow() >= ORDER_SUMMARY_TEMPLATE_ROW_INDEX) {
sheet.removeMergedRegion(index);
}
}
sheet.addMergedRegion(new CellRangeAddress(ORDER_TITLE_ROW_INDEX, ORDER_TITLE_ROW_INDEX,
0, ORDER_EXPORT_COLUMN_COUNT - 1));
sheet.removeRow(summaryTemplate);
sheet.removeRow(detailTemplate);
return new OrderExportTemplateContext(detailStyles, summaryStyles, detailRowHeight, summaryRowHeight);
}
/**
* 在内存中把既有十二列表格扩展为用户基础信息、身份信息和订单信息完整列。
*
* @param sheet 订单模板工作表
* @param titleRow 标题模板行
* @param headerRow 表头模板行
* @param detailTemplate 明细占位行
* @param summaryTemplate 合计占位行
*/
private void extendTemplateColumns(Sheet sheet, Row titleRow, Row headerRow, Row detailTemplate,
Row summaryTemplate) {
if (ORDER_DETAIL_STYLE_SOURCE_COLUMNS.length != ORDER_EXPORT_COLUMN_COUNT
|| ORDER_SUMMARY_STYLE_SOURCE_COLUMNS.length != ORDER_EXPORT_COLUMN_COUNT
|| ORDER_EXPORT_COLUMN_WIDTHS.length != ORDER_EXPORT_COLUMN_COUNT) {
throw new IllegalStateException("订单导出列配置数量不一致");
}
Cell titleTemplateCell = titleRow.getCell(0);
Cell headerTemplateCell = headerRow.getCell(0);
if (titleTemplateCell == null || headerTemplateCell == null) {
throw new IllegalStateException("订单导出模板缺少标题或表头样式");
}
CellStyle titleStyle = titleTemplateCell.getCellStyle();
CellStyle headerStyle = headerTemplateCell.getCellStyle();
CellStyle[] detailSourceStyles = readOriginalTemplateStyles(detailTemplate);
CellStyle[] summarySourceStyles = readOriginalTemplateStyles(summaryTemplate);
for (int column = 0; column < ORDER_EXPORT_COLUMN_COUNT; column++) {
Cell titleCell = getOrCreateCell(titleRow, column);
titleCell.setCellStyle(titleStyle);
Cell headerCell = getOrCreateCell(headerRow, column);
headerCell.setCellStyle(headerStyle);
headerCell.setCellValue(ORDER_EXPORT_HEADERS[column]);
getOrCreateCell(detailTemplate, column)
.setCellStyle(detailSourceStyles[ORDER_DETAIL_STYLE_SOURCE_COLUMNS[column]]);
getOrCreateCell(summaryTemplate, column)
.setCellStyle(summarySourceStyles[ORDER_SUMMARY_STYLE_SOURCE_COLUMNS[column]]);
sheet.setColumnWidth(column, ORDER_EXPORT_COLUMN_WIDTHS[column] * 256);
}
}
/**
* 获取模板行单元格,不存在时按指定列创建。
*
* @param row 模板行
* @param column Excel 零基列号
* @return 已存在或新建的单元格
*/
private Cell getOrCreateCell(Row row, int column) {
Cell cell = row.getCell(column);
return cell == null ? row.createCell(column) : cell;
}
/**
* 在扩展列之前读取原十二列表格的样式快照,避免映射过程中覆盖后续样式来源。
*
* @param row 原模板明细或合计占位行
* @return 按原 A-L 列顺序排列的样式快照
*/
private CellStyle[] readOriginalTemplateStyles(Row row) {
CellStyle[] styles = new CellStyle[ORDER_ORIGINAL_TEMPLATE_COLUMN_COUNT];
for (int column = 0; column < ORDER_ORIGINAL_TEMPLATE_COLUMN_COUNT; column++) {
Cell cell = row.getCell(column);
if (cell == null) {
throw new IllegalStateException("订单导出模板缺少第 " + (column + 1) + " 列样式");
}
styles[column] = cell.getCellStyle();
}
return styles;
}
/**
* 读取模板行每一列的样式,确保新增行延续原模板的字体、边框和对齐方式。
*
* @param row 明细或合计模板行
* @return 按 A-U 列顺序排列的样式数组
*/
private CellStyle[] readRowStyles(Row row) {
CellStyle[] styles = new CellStyle[ORDER_EXPORT_COLUMN_COUNT];
for (int column = 0; column < ORDER_EXPORT_COLUMN_COUNT; column++) {
Cell cell = row.getCell(column);
if (cell != null) {
styles[column] = cell.getCellStyle();
}
}
return styles;
}
/**
* 把一条订单写入模板明细行,并在写入边界完成业务编码和金额单位转换。
*
* @param sheet 订单导出工作表
* @param rowIndex 当前 Excel 零基行号
* @param dataIndex 当前订单在全部导出数据中的零基序号
* @param item 当前订单及其完整用户、商户导出资料
* @param context 模板样式和跨批次金额汇总上下文
*/
private void writeDetailRow(SXSSFSheet sheet, int rowIndex, long dataIndex, OrderExportRow item,
OrderExportTemplateContext context) {
private HardOrderExportExcelVo toExcelRow(OrderExportRow item, long lineNumber) {
HardOrderVo order = item.order;
UserVO user = item.user;
Row row = sheet.createRow(rowIndex);
row.setHeight(context.detailRowHeight);
setCell(row, 0, context.detailStyles[0], dataIndex + 1L);
setCell(row, 1, context.detailStyles[1], item.merchantName);
setCell(row, 2, context.detailStyles[2], formatUserId(order.getUserId()));
setCell(row, 3, context.detailStyles[3], resolveUserName(user, order));
setCell(row, 4, context.detailStyles[4], user == null ? null : user.getEmail());
setCell(row, 5, context.detailStyles[5], user == null ? order.getUserDeptName()
: StrUtil.blankToDefault(user.getDeptName(), order.getUserDeptName()));
setCell(row, 6, context.detailStyles[6], user == null ? null : joinPostNames(user.getPostList()));
setCell(row, 7, context.detailStyles[7], user == null ? order.getUserMobile()
HardOrderExportExcelVo row = new HardOrderExportExcelVo();
row.setLineNumber(Long.toString(lineNumber));
row.setOrderType(resolveOrderType(order.getOrderType()));
row.setOrderStatus(resolveOrderStatus(order.getOrderStatus()));
row.setSaleSourceName(order.getSaleSourceName());
row.setCreateTime(formatDateTime(order.getCreateTime()));
row.setUserName(resolveUserName(user, order));
row.setPhone(user == null ? order.getUserMobile()
: StrUtil.blankToDefault(user.getPhone(), order.getUserMobile()));
setCell(row, 8, context.detailStyles[8], user == null ? null : joinIdentityNames(user.getIdentityTypes()));
setCell(row, 9, context.detailStyles[9], user == null || user.getStudentInfo() == null
row.setDeptName(user == null ? order.getUserDeptName()
: StrUtil.blankToDefault(user.getDeptName(), order.getUserDeptName()));
row.setPostNames(user == null ? null : joinPostNames(user.getPostList()));
row.setIdentityTypeNames(user == null ? null : joinIdentityNames(user.getIdentityTypes()));
row.setStudentNo(user == null || user.getStudentInfo() == null
? null : user.getStudentInfo().getStudentNo());
setCell(row, 10, context.detailStyles[10], user == null || user.getStudentInfo() == null
row.setSchoolName(user == null || user.getStudentInfo() == null
? null : user.getStudentInfo().getSchoolName());
setCell(row, 11, context.detailStyles[11], user == null || user.getStudentInfo() == null
row.setClassName(user == null || user.getStudentInfo() == null
? null : user.getStudentInfo().getClassName());
setCell(row, 12, context.detailStyles[12], user == null || user.getStudentInfo() == null
row.setGuardianPhones(user == null || user.getStudentInfo() == null
? null : user.getStudentInfo().getGuardianPhones());
setCell(row, 13, context.detailStyles[13], user == null || user.getTeacherInfo() == null
row.setTeacherNo(user == null || user.getTeacherInfo() == null
? null : user.getTeacherInfo().getTeacherNo());
setCell(row, 14, context.detailStyles[14], order.getProductDesc());
setCell(row, 15, context.detailStyles[15], resolvePayType(order.getPayType()));
setCell(row, 16, context.detailStyles[16], resolveHourType(order.getHourType()));
setCell(row, 17, context.detailStyles[17], formatPrice(order.getTotalPrice()));
setCell(row, 18, context.detailStyles[18], formatPrice(order.getExtraPrice()));
setCell(row, 19, context.detailStyles[19], formatPrice(order.getPayPrice()));
setCell(row, 20, context.detailStyles[20], formatDateTime(order.getPayTime()));
row.setMerchantName(item.merchantName);
row.setHardName(order.getHardName());
row.setHardType(resolveHardType(order.getHardType()));
row.setProductDesc(order.getProductDesc());
row.setHourType(resolveHourType(order.getHourType()));
row.setApptUseDate(order.getApptUseDate());
row.setPayType(resolvePayType(order.getPayType()));
row.setTotalPrice(formatPrice(order.getTotalPrice()));
row.setExtraPrice(formatPrice(order.getExtraPrice()));
row.setDeliveryPrice(formatPrice(order.getDeliveryPrice()));
row.setPayPrice(formatPrice(order.getPayPrice()));
row.setOrderActualPrice(formatOrderActualPrice(order.getPayPrice(), order.getRefundPrice()));
row.setPayTime(formatDateTime(order.getPayTime()));
row.setVerificationTime(formatDateTime(order.getVerificationTime()));
row.setRefundStatus(resolveRefundStatus(order.getRefundStatus()));
row.setRefundPrice(formatPrice(order.getRefundPrice()));
row.setRefundTime(formatDateTime(order.getRefundTime()));
return row;
}
/**
* 在所有订单明细之后写入金额合计,并合并金额列之前的说明区域。
* 生成与明细相同结构的金额合计行,确保 EasyExcel 保持二级表头列顺序。
*
* @param sheet 订单导出工作表
* @param rowIndex 合计行的 Excel 零基行号
* @param context 模板样式和跨批次金额汇总上下文
* @param summary 全部分片累计的订单金额
* @return 首列显示“合计”且金额列写入元金额的导出行
*/
private void writeSummaryRow(SXSSFSheet sheet, int rowIndex, OrderExportTemplateContext context) {
Row row = sheet.createRow(rowIndex);
row.setHeight(context.summaryRowHeight);
for (int column = 0; column < ORDER_EXPORT_COLUMN_COUNT; column++) {
setCell(row, column, context.summaryStyles[column], null);
}
row.getCell(0).setCellValue("合计");
row.getCell(ORDER_TOTAL_PRICE_COLUMN_INDEX)
.setCellValue(context.summary.formatPrice(context.summary.totalPriceSum).doubleValue());
row.getCell(ORDER_EXTRA_PRICE_COLUMN_INDEX)
.setCellValue(context.summary.formatPrice(context.summary.extraPriceSum).doubleValue());
row.getCell(ORDER_PAY_PRICE_COLUMN_INDEX)
.setCellValue(context.summary.formatPrice(context.summary.payPriceSum).doubleValue());
sheet.addMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0,
ORDER_SUMMARY_MERGE_LAST_COLUMN_INDEX));
}
/**
* 将用户主键按文本写入 Excel,避免雪花主键超过双精度整数范围后发生尾数丢失。
*
* @param userId UPMS 用户主键,允许为空
* @return 十进制文本;输入为空时返回空
*/
private String formatUserId(Long userId) {
return userId == null ? null : userId.toString();
private HardOrderExportExcelVo toSummaryExcelRow(OrderExportSummary summary) {
HardOrderExportExcelVo row = new HardOrderExportExcelVo();
row.setLineNumber("合计");
row.setTotalPrice(summary.formatPrice(summary.totalPriceSum));
row.setExtraPrice(summary.formatPrice(summary.extraPriceSum));
row.setDeliveryPrice(summary.formatPrice(summary.deliveryPriceSum));
row.setPayPrice(summary.formatPrice(summary.payPriceSum));
row.setOrderActualPrice(summary.formatPrice(summary.payPriceSum.subtract(summary.refundPriceSum)));
row.setRefundPrice(summary.formatPrice(summary.refundPriceSum));
return row;
}
/**
* 依次按姓名、昵称、登录账号和订单历史展示名确定导出姓名。
*
* @param user UPMS 返回的完整用户资料,历史订单用户不存在时为空
* @param user UPMS 返回的完整用户资料,历史订单用户不存在时为空
* @param order 当前订单,用于用户资料缺失时兼容历史展示字段
* @return 用户导出姓名;所有来源均为空时返回空文本
*/
@@ -685,40 +572,57 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
}
/**
* 为单元格设置模板样式和值,空值仍创建单元格以保留完整边框。
* 将订单类型协议编码转换为中文描述,未知历史编码原样保留。
*
* @param row 目标行
* @param column 目标列零基下标
* @param style 模板样式,允许为空
* @param value 单元格展示值,数字保持数字类型,其余按文本写入
* @param orderType 数据库和跨端接口使用的订单类型编码
* @return 订单类型中文描述、原始未知编码或空值
*/
private void setCell(Row row, int column, CellStyle style, Object value) {
Cell cell = row.createCell(column);
if (style != null) {
cell.setCellStyle(style);
}
if (value instanceof Number number) {
cell.setCellValue(number.doubleValue());
} else if (value != null) {
cell.setCellValue(value.toString());
private String resolveOrderType(String orderType) {
if (StrUtil.isBlank(orderType)) {
return orderType;
}
HardOrderTypeEnum value = HardOrderTypeEnum.valueOfByType(orderType);
return value == null ? orderType : value.getDescription();
}
/**
* 生成订单导出标题;仅支付时间范围沿用旧模板的标题展示规则。
* 将订单状态协议编码安全转换为中文描述。
*
* @param query 订单筛选条件
* @return 包含支付时间范围的订单交易汇总标题,未选择范围时返回固定标题
* <p>既有枚举解析方法对未知编码会抛异常,因此导出侧遍历枚举匹配,确保历史脏数据仍可导出。</p>
*
* @param orderStatus 数据库和跨端接口使用的订单状态编码
* @return 订单状态中文描述、原始未知编码或空值
*/
private String buildTitle(HardOrderDto query) {
if (query.getPayTimeList() != null && query.getPayTimeList().length == 2
&& query.getPayTimeList()[0] != null && query.getPayTimeList()[1] != null) {
return LocalDateTimeUtil.format(query.getPayTimeList()[0], DatePattern.NORM_DATETIME_PATTERN)
+ " 到 "
+ LocalDateTimeUtil.format(query.getPayTimeList()[1], DatePattern.NORM_DATETIME_PATTERN)
+ "订单交易汇总";
private String resolveOrderStatus(String orderStatus) {
if (StrUtil.isBlank(orderStatus)) {
return orderStatus;
}
return "订单交易汇总";
for (HardOrderStatusEnum value : HardOrderStatusEnum.values()) {
if (Objects.equals(value.getType(), orderStatus)) {
return value.getDescription();
}
}
return orderStatus;
}
/**
* 将设备类型协议编码安全转换为中文描述。
*
* <p>既有枚举解析方法对未知编码会抛异常,因此导出侧遍历枚举匹配并保留历史编码。</p>
*
* @param hardType 硬件设备类型编码
* @return 设备类型中文描述、原始未知编码或空值
*/
private String resolveHardType(String hardType) {
if (StrUtil.isBlank(hardType)) {
return hardType;
}
for (HardTypeEnum value : HardTypeEnum.values()) {
if (Objects.equals(value.getType(), hardType)) {
return value.getDescription();
}
}
return hardType;
}
/**
@@ -736,17 +640,35 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
}
/**
* 将餐段协议编码转换为现有枚举中文描述,未知编码沿用旧导出规则输出空文本。
* 将餐段协议编码转换为现有枚举中文描述,未知历史编码原样保留。
*
* @param hourType 用餐时间段协议编码
* @return 餐段中文描述、空值或空文本
* @return 餐段中文描述、原始未知编码或空值
*/
private String resolveHourType(String hourType) {
if (StrUtil.isBlank(hourType)) {
return hourType;
}
HardHourTypeEnum value = HardHourTypeEnum.valueOfByType(hourType);
return value == null ? "" : value.getDescription();
return value == null ? hourType : value.getDescription();
}
/**
* 将订单退款状态编码转换为中文名称,未知历史编码原样保留。
*
* @param refundStatus hard_order.refund_status 中保存的退款状态编码
* @return 退款状态中文名称、原始未知编码或空值
*/
private String resolveRefundStatus(String refundStatus) {
if (StrUtil.isBlank(refundStatus)) {
return refundStatus;
}
for (HardRefundStatusEnum value : HardRefundStatusEnum.values()) {
if (Objects.equals(value.getStatus(), refundStatus)) {
return value.getName();
}
}
return refundStatus;
}
/**
@@ -761,24 +683,37 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
}
/**
* 将订单支付时间转换为模板使用的标准日期时间文本。
* 计算订单实际金额并转换为元,退款为空时按零分处理,允许结果为负数以保留真实差额。
*
* @param dateTime 订单支付时间,允许为空
* @param payPrice 订单实际支付金额,单位为分;允许为空
* @param refundPrice 订单已退款金额,单位为分;允许为空
* @return 订单实际金额,单位为元并保留两位小数
*/
private BigDecimal formatOrderActualPrice(Number payPrice, Number refundPrice) {
BigDecimal paid = payPrice == null ? BigDecimal.ZERO : BigDecimal.valueOf(payPrice.longValue());
BigDecimal refunded = refundPrice == null ? BigDecimal.ZERO : BigDecimal.valueOf(refundPrice.longValue());
return paid.subtract(refunded).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
}
/**
* 将订单日期时间转换为 Excel 统一使用的标准文本。
*
* @param dateTime 订单业务日期时间,允许为空
* @return yyyy-MM-dd HH:mm:ss 文本;输入为空时返回空
*/
private String formatDateTime(LocalDateTime dateTime) {
return dateTime == null ? null : LocalDateTimeUtil.format(dateTime, DatePattern.NORM_DATETIME_PATTERN);
return dateTime == null ? null : dateTime.format(EXCEL_DATE_TIME_FORMATTER);
}
/**
* 上传完整订单工作簿并校验文件中心业务响应。
*
* @param file 已关闭并可读取的完整 XLSX 临时文件
* @param file 已关闭并可读取的完整 XLSX 临时文件
* @param fileName 用户下载时使用的文件名
* @return 文件中心提供的下载地址
* @throws Exception 文件读取或远程上传失败时抛出
*/
private String uploadExportFile(java.nio.file.Path file, String fileName) throws Exception {
private String uploadExportFile(Path file, String fileName) throws Exception {
MultipartFile exportFile = new MockMultipartFile("file", encodeUploadOriginalFilename(fileName),
ORDER_EXPORT_CONTENT_TYPE, Files.readAllBytes(file));
R<?> uploadResult = remoteFileService.innerUpload(exportFile, ORDER_EXPORT_FILE_DIR, null,
@@ -804,6 +739,22 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
}
/**
* 清理订单导出临时文件;清理失败只记录告警,不覆盖原始导出或上传结果。
*
* @param outputPath 待删除的临时 XLSX 路径,允许为空
*/
private void deleteTemporaryExportFile(Path outputPath) {
if (outputPath == null) {
return;
}
try {
Files.deleteIfExists(outputPath);
} catch (Exception exception) {
log.warn("|{}|清理临时文件失败,path:{}", LOG_TAG, outputPath, exception);
}
}
/**
* 冻结当前用户允许查看的设备类型、身份用户范围和单商户的子商户范围。
*
@@ -872,11 +823,13 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
target.setHardIds(copyList(source.getHardIds()));
target.setMchIds(copyList(source.getMchIds()));
target.setMchTypeCodes(copyList(source.getMchTypeCodes()));
target.setMerchantTypeCodes(copyList(source.getMerchantTypeCodes()));
target.setOrderStatusCodes(copyList(source.getOrderStatusCodes()));
target.setOrderTypeCodes(copyList(source.getOrderTypeCodes()));
target.setOrderPayTypeCodes(copyList(source.getOrderPayTypeCodes()));
target.setExcludeOrderPayTypeCodes(copyList(source.getExcludeOrderPayTypeCodes()));
target.setHardTypeCodes(copyList(source.getHardTypeCodes()));
target.setHourTypeCodes(copyList(source.getHourTypeCodes()));
target.setRefundStatusCodes(copyList(source.getRefundStatusCodes()));
target.setSaleSourceCodes(copyList(source.getSaleSourceCodes()));
target.setSaleStatusCodes(copyList(source.getSaleStatusCodes()));
@@ -899,7 +852,7 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
* 复制可变列表,保留空值语义并隔离调用方后续修改。
*
* @param source 原始列表,允许为空
* @param <T> 列表元素类型
* @param <T> 列表元素类型
* @return 独立列表副本;输入为空时返回空
*/
private <T> List<T> copyList(List<T> source) {
@@ -946,62 +899,31 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
return target;
}
/**
* 订单导出模板样式和跨批次金额汇总上下文。
*/
private static final class OrderExportTemplateContext {
/** 明细行 A-U 列的模板样式。 */
private final CellStyle[] detailStyles;
/** 合计行 A-U 列的模板样式。 */
private final CellStyle[] summaryStyles;
/** 明细模板行高度,单位为 POI twip。 */
private final short detailRowHeight;
/** 合计模板行高度,单位为 POI twip。 */
private final short summaryRowHeight;
/** 跨分页累计的订单金额汇总。 */
private final OrderExportSummary summary = new OrderExportSummary();
/**
* 创建订单导出模板上下文。
*
* @param detailStyles 明细行 A-U 列样式
* @param summaryStyles 合计行 A-U 列样式
* @param detailRowHeight 明细行高度
* @param summaryRowHeight 合计行高度
*/
private OrderExportTemplateContext(CellStyle[] detailStyles, CellStyle[] summaryStyles,
short detailRowHeight, short summaryRowHeight) {
this.detailStyles = detailStyles;
this.summaryStyles = summaryStyles;
this.detailRowHeight = detailRowHeight;
this.summaryRowHeight = summaryRowHeight;
}
}
/**
* 单条订单导出行,组合数据库订单、UPMS 完整用户资料和钱包商户名称。
*/
private static final class OrderExportRow {
/** 当前数据权限和筛选条件命中的订单记录。 */
/**
* 当前数据权限和筛选条件命中的订单记录。
*/
private final HardOrderVo order;
/** UPMS 返回的完整用户导出资料;历史用户不存在或不可见时为空。 */
/**
* UPMS 返回的完整用户导出资料;历史用户不存在或不可见时为空。
*/
private final UserVO user;
/** 钱包服务返回的商户名称;历史商户不存在时为空。 */
/**
* 钱包服务返回的商户名称;历史商户不存在时为空。
*/
private final String merchantName;
/**
* 创建订单导出行。
*
* @param order 当前订单记录
* @param user 与订单用户主键匹配的完整用户资料
* @param order 当前订单记录
* @param user 与订单用户主键匹配的完整用户资料
* @param merchantName 与订单商户主键匹配的商户名称
*/
private OrderExportRow(HardOrderVo order, UserVO user, String merchantName) {
@@ -1016,31 +938,49 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
*/
private static final class OrderExportSummary {
/** 商品总价累计值,单位为分。 */
/**
* 商品总价累计值,单位为分。
*/
private BigDecimal totalPriceSum = BigDecimal.ZERO;
/** 额外支付累计值,单位为分。 */
/**
* 额外支付累计值,单位为分。
*/
private BigDecimal extraPriceSum = BigDecimal.ZERO;
/** 实际支付累计值,单位为分。 */
/**
* 配送费用累计值,单位为分。
*/
private BigDecimal deliveryPriceSum = BigDecimal.ZERO;
/**
* 实际支付累计值,单位为分。
*/
private BigDecimal payPriceSum = BigDecimal.ZERO;
/**
* 累计当前订单的三个金额字段,历史空值按零分处理。
* 退款金额累计值,单位为分。
*/
private BigDecimal refundPriceSum = BigDecimal.ZERO;
/**
* 累计当前订单的商品、额外、配送、实付和退款金额,历史空值按零分处理。
*
* @param item 当前订单及其导出关联资料
*/
private void add(OrderExportRow item) {
totalPriceSum = addAmount(totalPriceSum, item.order.getTotalPrice());
extraPriceSum = addAmount(extraPriceSum, item.order.getExtraPrice());
deliveryPriceSum = addAmount(deliveryPriceSum, item.order.getDeliveryPrice());
payPriceSum = addAmount(payPriceSum, item.order.getPayPrice());
refundPriceSum = addAmount(refundPriceSum, item.order.getRefundPrice());
}
/**
* 将非空分金额加入当前累计值。
*
* @param current 当前累计金额,单位为分
* @param amount 待累计金额,单位为分;允许为空
* @param amount 待累计金额,单位为分;允许为空
* @return 累加后的分金额
*/
private BigDecimal addAmount(BigDecimal current, Number amount) {
@@ -52,11 +52,15 @@
a.refund_status,
a.verification_time,
a.apply_cancel_time,
a.unfollow_time,
a.android_order_code,
a.is_offline_order,
a.product_desc,
a.offline_pay_success,
a.address_name,
a.shipment_no,
a.shipped_time,
a.delivered_time,
a.create_by,
a.create_time,
a.update_by,
@@ -90,6 +94,13 @@
<if test="dto.merchantType != null and dto.merchantType != '' ">
and a.merchant_type = #{dto.merchantType}
</if>
<!-- 页面商户类型多选与服务端注入的 mchTypeCodes 权限条件分别保留,最终按交集筛选。 -->
<if test="dto.merchantTypeCodes != null and dto.merchantTypeCodes.size() != 0">
and a.merchant_type in
<foreach collection="dto.merchantTypeCodes" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.orderType != null and dto.orderType != '' ">
and a.order_type = #{dto.orderType}
</if>
@@ -102,6 +113,12 @@
<if test="dto.hourType != null and dto.hourType != '' ">
and a.hour_type = #{dto.hourType}
</if>
<if test="dto.hourTypeCodes != null and dto.hourTypeCodes.size() != 0">
and a.hour_type in
<foreach collection="dto.hourTypeCodes" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.hourTypeIsNotEmpty != null and dto.hourTypeIsNotEmpty == true ">
and ( a.hour_type is not null and a.hour_type not in ('-1') )
</if>
@@ -0,0 +1,34 @@
package com.bsd.pjcloud.thirdparty.dto.pay;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 第三方支付统计查询条件。
*
* <p>统计始终按支付订单的支付时间归属,时间范围由管理端明确传入,避免空条件扫描全历史支付数据。</p>
*/
@Data
@Schema(description = "第三方支付统计查询条件")
public class ThirdPayTotalQueryDto {
/**
* 支付所属商户主键;为空表示查询当前权限范围内全部商户。
*/
@Schema(description = "商户ID")
private Long mchId;
/**
* 支付平台类型编码,沿用 mini_platform_type 字典;为空表示全部支付方式。
*/
@Schema(description = "支付平台类型")
private String payPlatformType;
/**
* 支付时间范围,必须包含开始和结束两个时间点,最大跨度一年。
*/
@Schema(description = "支付时间范围")
private LocalDateTime[] payTimeList;
}
@@ -0,0 +1,60 @@
package com.bsd.pjcloud.thirdparty.vo.pay;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 第三方支付统计的商户与支付方式明细。
*
* <p>金额字段统一使用分传输;管理端展示时再转换为元。退款快照无法还原金额时,
* 对应不可核对数量会增加且不会把未知金额当作零。</p>
*/
@Data
@Schema(description = "第三方支付统计明细")
public class ThirdPayTotalRowVo {
/** 商户主键;历史订单缺失商户时允许为空。 */
private Long mchId;
/** 商户名称;远程商户服务不可用时返回商户信息不可用。 */
private String mchName;
/** 支付平台类型编码,未知历史编码原样保留。 */
private String payPlatformType;
/** 支付成功订单数,按支付订单主键去重。 */
private Long payOrderCount = 0L;
/** 支付成功金额,单位分。 */
private Long payAmount = 0L;
/** 退款成功订单数,按支付订单主键去重。 */
private Long refundOrderCount = 0L;
/** 可从退款快照解析出的退款金额,单位分。 */
private Long refundAmount = 0L;
/** 分账成功记录数。 */
private Long fzSuccessCount = 0L;
/** 分账成功金额,单位分。 */
private Long fzSuccessAmount = 0L;
/** 成功退分账记录数。 */
private Long fzRefundSuccessCount = 0L;
/** 可从退分账快照解析出的金额,单位分。 */
private Long fzRefundSuccessAmount = 0L;
/** 分账成功金额扣除已解析退分账金额后的平台净收款,单位分。 */
private Long platformNetAmount;
/** 退分账金额是否全部可由历史快照核对。 */
private boolean platformNetAmountComplete = true;
/** 退款成功但金额无法从历史快照解析的订单数。 */
private Long refundAmountUnavailableCount = 0L;
/** 成功退分账但金额无法从历史快照解析的记录数。 */
private Long fzRefundAmountUnavailableCount = 0L;
}
@@ -0,0 +1,21 @@
package com.bsd.pjcloud.thirdparty.vo.pay;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 第三方支付统计接口返回值。
*/
@Data
@Schema(description = "第三方支付统计结果")
public class ThirdPayTotalStatisticsVo {
/** 按商户和支付方式分组的明细,未知维度也必须保留。 */
private List<ThirdPayTotalRowVo> rows = new ArrayList<>();
/** 当前查询范围内全部明细的合计。 */
private ThirdPayTotalSummaryVo total = new ThirdPayTotalSummaryVo();
}
@@ -0,0 +1,12 @@
package com.bsd.pjcloud.thirdparty.vo.pay;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* 第三方支付统计范围合计。
*
* <p>字段口径与明细一致,维度字段为空表示全部商户和支付方式。</p>
*/
@Schema(description = "第三方支付统计合计")
public class ThirdPayTotalSummaryVo extends ThirdPayTotalRowVo {
}
@@ -0,0 +1,43 @@
package com.bsd.pjcloud.thirdparty.controller.pay;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayTotalQueryDto;
import com.bsd.pjcloud.thirdparty.service.ThirdPayTotalService;
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalStatisticsVo;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 第三方支付统计接口。
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/third-pay-total")
@Tag(description = "third-pay-total", name = "第三方支付统计")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class ThirdPayTotalController {
/** 第三方支付统计服务,负责校验范围并完成聚合解析。 */
private final ThirdPayTotalService thirdPayTotalService;
/**
* 按商户和支付方式统计支付订单、退款、分账和平台净收款。
*
* @param query 统计查询条件,支付时间范围必须完整且不超过一年
* @return 统一响应包装的统计结果
*/
@PostMapping("/statistics")
@Operation(summary = "第三方支付统计", description = "按商户和支付方式统计支付、退款、分账及平台净收款")
@PreAuthorize("@pms.hasPermission('third_thirdPayTotal_view')")
public R<ThirdPayTotalStatisticsVo> statistics(@RequestBody ThirdPayTotalQueryDto query) {
return R.ok(thirdPayTotalService.statistics(query));
}
}
@@ -0,0 +1,51 @@
package com.bsd.pjcloud.thirdparty.mapper.pay;
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayTotalQueryDto;
import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalAggregateRecord;
import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalFzRefundRecord;
import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalRefundRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 第三方支付统计数据访问接口。
*
* <p>支付和分账分别聚合,退款快照单独读取,避免支付订单与多条分账记录直接连接后重复累加金额。</p>
*/
@Mapper
public interface ThirdPayTotalMapper {
/**
* 查询支付成功订单的分组数量与金额。
*
* @param query 已校验且包含完整支付时间范围的查询条件
* @return 按商户和支付方式分组的成功支付聚合;无数据时返回空列表
*/
List<ThirdPayTotalAggregateRecord> selectPaySuccessAggregates(@Param("query") ThirdPayTotalQueryDto query);
/**
* 查询退款成功订单的历史快照,每个支付订单最多返回一条。
*
* @param query 已校验且包含完整支付时间范围的查询条件
* @return 退款成功订单及三级退款快照,供 Service 按渠道精确解析金额
*/
List<ThirdPayTotalRefundRecord> selectRefundRecords(@Param("query") ThirdPayTotalQueryDto query);
/**
* 查询分账成功记录的分组数量与金额。
*
* @param query 已校验且包含完整支付时间范围的查询条件
* @return 按关联支付订单商户和支付方式分组的分账成功聚合
*/
List<ThirdPayTotalAggregateRecord> selectFzSuccessAggregates(@Param("query") ThirdPayTotalQueryDto query);
/**
* 查询成功退分账记录及其快照。
*
* @param query 已校验且包含完整支付时间范围的查询条件
* @return 每条成功退分账记录及其请求、响应快照
*/
List<ThirdPayTotalFzRefundRecord> selectFzRefundRecords(@Param("query") ThirdPayTotalQueryDto query);
}
@@ -0,0 +1,18 @@
package com.bsd.pjcloud.thirdparty.service;
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayTotalQueryDto;
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalStatisticsVo;
/**
* 第三方支付统计服务。
*/
public interface ThirdPayTotalService {
/**
* 按商户和支付方式查询支付、退款、分账及平台净收款统计。
*
* @param query 已校验的支付时间、商户和支付方式条件
* @return 统计明细及范围合计,历史未知维度不会被丢弃
*/
ThirdPayTotalStatisticsVo statistics(ThirdPayTotalQueryDto query);
}
@@ -0,0 +1,556 @@
package com.bsd.pjcloud.thirdparty.service.impl.pay;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayTotalQueryDto;
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayTotalMapper;
import com.bsd.pjcloud.thirdparty.service.ThirdPayTotalService;
import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalAggregateRecord;
import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalFzRefundRecord;
import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalRefundRecord;
import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalStatisticsSupport;
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalRowVo;
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalStatisticsVo;
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalSummaryVo;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 第三方支付统计服务实现。
*
* <p>支付成功、退款成功、分账成功和退分账成功分别查询;退款快照只保留当前退款事实,
* 因此金额无法还原时记录不可核对数量,并保留已解析的部分金额。</p>
*/
@Service
@Validated
@Slf4j
@RequiredArgsConstructor
public class ThirdPayTotalServiceImpl implements ThirdPayTotalService {
/** 统计查询 Mapper,负责按支付时间和当前租户数据聚合事实。 */
private final ThirdPayTotalMapper thirdPayTotalMapper;
/** 商户远程服务,用于批量补充统计行的商户名称。 */
private final RemoteMchService remoteMchService;
/** 微信支付平台编码,持久化于 third_pay_order.pay_platform_type。 */
private static final String WECHAT_PLATFORM = "10";
/** 支付宝支付平台编码,持久化于 third_pay_order.pay_platform_type。 */
private static final String ALIPAY_PLATFORM = "20";
/** 退款快照解析优先级,必须先读取最终回调再读取渠道响应和发起请求。 */
private static final List<String> WECHAT_REFUND_KEYS = List.of("refund_fee", "settlement_refund_fee", "refund");
/** 支付宝退款快照中的金额字段优先级,单位为元。 */
private static final List<String> ALIPAY_REFUND_KEYS = List.of("refund_fee", "send_back_fee", "refundAmount");
/**
* 统计指定支付时间范围内的四类支付事实,并在内存中合并到商户与支付方式维度。
*
* @param query 已包含完整日期范围的查询条件
* @return 统计明细及合计
*/
@Override
public ThirdPayTotalStatisticsVo statistics(ThirdPayTotalQueryDto query) {
validateQuery(query);
Map<ThirdPayTotalStatisticsSupport, ThirdPayTotalRowVo> rowMap = new LinkedHashMap<>();
applyAggregates(rowMap, thirdPayTotalMapper.selectPaySuccessAggregates(query), true);
applyRefundRecords(rowMap, thirdPayTotalMapper.selectRefundRecords(query));
applyAggregates(rowMap, thirdPayTotalMapper.selectFzSuccessAggregates(query), false);
applyFzRefundRecords(rowMap, thirdPayTotalMapper.selectFzRefundRecords(query));
List<ThirdPayTotalRowVo> rows = new ArrayList<>(rowMap.values());
rows.sort(Comparator.comparing(ThirdPayTotalRowVo::getMchId, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(ThirdPayTotalRowVo::getPayPlatformType, Comparator.nullsLast(Comparator.naturalOrder())));
fillMerchantNames(rows);
ThirdPayTotalSummaryVo total = summarize(rows);
ThirdPayTotalStatisticsVo result = new ThirdPayTotalStatisticsVo();
result.setRows(rows);
result.setTotal(total);
return result;
}
/**
* 校验时间范围,拒绝空范围和超过一年的全历史扫描。
*
* @param query 统计查询条件
*/
void validateQuery(ThirdPayTotalQueryDto query) {
if (query == null || query.getPayTimeList() == null || query.getPayTimeList().length != 2
|| query.getPayTimeList()[0] == null || query.getPayTimeList()[1] == null) {
throw new IllegalArgumentException("支付时间范围必须完整填写");
}
LocalDateTime start = query.getPayTimeList()[0];
LocalDateTime end = query.getPayTimeList()[1];
if (start.isAfter(end)) {
throw new IllegalArgumentException("支付时间范围开始时间不能晚于结束时间");
}
if (end.isAfter(start.plusYears(1))) {
throw new IllegalArgumentException("支付统计时间范围不能超过一年");
}
}
/**
* 将数据库中的支付成功或分账成功聚合结果写入内存行。
*
* @param rowMap 统计行索引
* @param records SQL 聚合结果
* @param paySuccess 是否写入支付成功指标;否则写入分账成功指标
*/
private void applyAggregates(Map<ThirdPayTotalStatisticsSupport, ThirdPayTotalRowVo> rowMap,
List<ThirdPayTotalAggregateRecord> records, boolean paySuccess) {
if (records == null) return;
for (ThirdPayTotalAggregateRecord record : records) {
if (record == null) continue;
ThirdPayTotalRowVo row = row(rowMap, record.getMchId(), record.getPayPlatformType());
if (paySuccess) {
row.setPayOrderCount(safeAdd(row.getPayOrderCount(), zeroIfNull(record.getRecordCount())));
row.setPayAmount(safeAdd(row.getPayAmount(), zeroIfNull(record.getAmount())));
} else {
row.setFzSuccessCount(safeAdd(row.getFzSuccessCount(), zeroIfNull(record.getRecordCount())));
row.setFzSuccessAmount(safeAdd(row.getFzSuccessAmount(), zeroIfNull(record.getAmount())));
}
}
}
/**
* 归并退款成功订单并解析三级退款快照。
*
* @param rowMap 统计行索引
* @param records 退款成功订单快照列表
*/
private void applyRefundRecords(Map<ThirdPayTotalStatisticsSupport, ThirdPayTotalRowVo> rowMap,
List<ThirdPayTotalRefundRecord> records) {
if (records == null) return;
for (ThirdPayTotalRefundRecord record : records) {
if (record == null) continue;
ThirdPayTotalRowVo row = row(rowMap, record.getMchId(), record.getPayPlatformType());
row.setRefundOrderCount(safeAdd(row.getRefundOrderCount(), 1L));
Long amount = parseRefundAmount(record);
if (amount == null) {
row.setRefundAmountUnavailableCount(safeAdd(row.getRefundAmountUnavailableCount(), 1L));
} else {
row.setRefundAmount(safeAdd(row.getRefundAmount(), amount));
}
}
}
/**
* 归并成功退分账记录,解析未知金额时将净收款标为部分可核对。
*
* @param rowMap 统计行索引
* @param records 成功退分账快照列表
*/
private void applyFzRefundRecords(Map<ThirdPayTotalStatisticsSupport, ThirdPayTotalRowVo> rowMap,
List<ThirdPayTotalFzRefundRecord> records) {
if (records == null) return;
for (ThirdPayTotalFzRefundRecord record : records) {
if (record == null) continue;
ThirdPayTotalRowVo row = row(rowMap, record.getMchId(), record.getPayPlatformType());
row.setFzRefundSuccessCount(safeAdd(row.getFzRefundSuccessCount(), 1L));
Long amount = parseFzRefundAmount(record);
if (amount == null) {
row.setFzRefundAmountUnavailableCount(safeAdd(row.getFzRefundAmountUnavailableCount(), 1L));
row.setPlatformNetAmountComplete(false);
} else {
row.setFzRefundSuccessAmount(safeAdd(row.getFzRefundSuccessAmount(), amount));
}
}
}
/**
* 获取或创建单个统计维度行,并初始化平台净收款为已知的分账成功金额。
*
* @param rowMap 统计行索引
* @param mchId 商户主键
* @param payPlatformType 支付方式编码
* @return 可变统计行
*/
private ThirdPayTotalRowVo row(Map<ThirdPayTotalStatisticsSupport, ThirdPayTotalRowVo> rowMap,
Long mchId, String payPlatformType) {
ThirdPayTotalStatisticsSupport key = ThirdPayTotalStatisticsSupport.of(mchId, payPlatformType);
return rowMap.computeIfAbsent(key, ignored -> {
ThirdPayTotalRowVo value = new ThirdPayTotalRowVo();
value.setMchId(mchId);
value.setPayPlatformType(key.payPlatformType());
return value;
});
}
/**
* 为每行计算平台净收款;未知退分账金额只影响完整性标记,不被当成零扣除。
*
* @param row 统计明细行
*/
private void calculateNetAmount(ThirdPayTotalRowVo row) {
row.setPlatformNetAmount(safeAdd(row.getFzSuccessAmount(), -zeroIfNull(row.getFzRefundSuccessAmount())));
}
/**
* 批量补充商户名称,远程失败时不丢弃统计维度。
*
* @param rows 统计明细
*/
private void fillMerchantNames(List<ThirdPayTotalRowVo> rows) {
if (rows == null || rows.isEmpty()) return;
List<Long> mchIds = rows.stream().map(ThirdPayTotalRowVo::getMchId).filter(id -> id != null).distinct().toList();
Map<Long, String> merchantMap = new HashMap<>();
if (!mchIds.isEmpty()) {
try {
R<List<MchManageVo>> response = remoteMchService.getByIds(mchIds);
if (response != null && response.isOk() && response.getData() != null) {
for (MchManageVo merchant : response.getData()) {
if (merchant != null && merchant.getId() != null && StrUtil.isNotBlank(merchant.getName())) {
merchantMap.putIfAbsent(merchant.getId(), merchant.getName());
}
}
}
} catch (Exception exception) {
log.warn("第三方支付统计补充商户名称失败,errorType:{}", exception.getClass().getSimpleName());
}
}
for (ThirdPayTotalRowVo row : rows) {
if (row.getMchId() == null) {
row.setMchName("未记录商户");
} else {
row.setMchName(merchantMap.getOrDefault(row.getMchId(), "商户信息不可用"));
}
calculateNetAmount(row);
}
}
/**
* 计算范围合计,完整性只要有一条明细不可核对就置为 false。
*
* @param rows 明细列表
* @return 合计行
*/
private ThirdPayTotalSummaryVo summarize(List<ThirdPayTotalRowVo> rows) {
ThirdPayTotalSummaryVo total = new ThirdPayTotalSummaryVo();
if (rows == null) return total;
for (ThirdPayTotalRowVo row : rows) {
total.setPayOrderCount(safeAdd(total.getPayOrderCount(), zeroIfNull(row.getPayOrderCount())));
total.setPayAmount(safeAdd(total.getPayAmount(), zeroIfNull(row.getPayAmount())));
total.setRefundOrderCount(safeAdd(total.getRefundOrderCount(), zeroIfNull(row.getRefundOrderCount())));
total.setRefundAmount(safeAdd(total.getRefundAmount(), zeroIfNull(row.getRefundAmount())));
total.setFzSuccessCount(safeAdd(total.getFzSuccessCount(), zeroIfNull(row.getFzSuccessCount())));
total.setFzSuccessAmount(safeAdd(total.getFzSuccessAmount(), zeroIfNull(row.getFzSuccessAmount())));
total.setFzRefundSuccessCount(safeAdd(total.getFzRefundSuccessCount(), zeroIfNull(row.getFzRefundSuccessCount())));
total.setFzRefundSuccessAmount(safeAdd(total.getFzRefundSuccessAmount(), zeroIfNull(row.getFzRefundSuccessAmount())));
total.setRefundAmountUnavailableCount(safeAdd(total.getRefundAmountUnavailableCount(), zeroIfNull(row.getRefundAmountUnavailableCount())));
total.setFzRefundAmountUnavailableCount(safeAdd(total.getFzRefundAmountUnavailableCount(), zeroIfNull(row.getFzRefundAmountUnavailableCount())));
total.setPlatformNetAmountComplete(total.isPlatformNetAmountComplete() && row.isPlatformNetAmountComplete());
}
calculateNetAmount(total);
return total;
}
/**
* 从退款订单按回调、响应、请求优先级解析退款金额。
*
* @param record 退款快照记录
* @return 退款金额(分);无法解析时返回空
*/
Long parseRefundAmount(ThirdPayTotalRefundRecord record) {
if (record == null) return null;
// Arrays.asList 允许历史快照为空;List.of 遇到任一 null 会在解析前直接抛出异常。
List<String> snapshots = Arrays.asList(record.getRefundCallbackRes(), record.getRefundRes(), record.getRefundRep());
for (String snapshot : snapshots) {
Long amount = parsePlatformAmount(snapshot, record.getPayPlatformType());
if (amount != null) return amount;
}
return null;
}
/**
* 从微信或支付宝退分账快照解析金额;支付宝金额先以 BigDecimal 元转分。
*
* @param record 退分账快照记录
* @return 退分账金额(分);无法解析时返回空
*/
Long parseFzRefundAmount(ThirdPayTotalFzRefundRecord record) {
if (record == null) return null;
if (WECHAT_PLATFORM.equals(record.getPayPlatformType())) {
Long amount = parseWechatFzAmount(record.getFzRefundRes());
return amount != null ? amount : parseWechatFzAmount(record.getFzRefundReq());
}
if (ALIPAY_PLATFORM.equals(record.getPayPlatformType())) {
Long amount = parseAlipayRoyaltyAmount(record.getFzRefundReq());
return amount != null ? amount : parseAlipayRoyaltyAmount(record.getFzRefundRes());
}
return null;
}
/**
* 解析普通支付退款快照金额。
*
* @param snapshot JSON 快照
* @param platform 支付平台编码
* @return 分金额;JSON 无法识别时为空
*/
private Long parsePlatformAmount(String snapshot, String platform) {
if (StrUtil.isBlank(snapshot)) return null;
try {
JSONObject object = JSONUtil.parseObj(snapshot);
if (WECHAT_PLATFORM.equals(platform)) {
return parseFenCandidate(object, WECHAT_REFUND_KEYS);
}
if (ALIPAY_PLATFORM.equals(platform)) {
return parseYuanCandidate(object, ALIPAY_REFUND_KEYS);
}
} catch (Exception exception) {
log.debug("第三方支付统计退款快照解析失败,errorType:{}", exception.getClass().getSimpleName());
}
return null;
}
/**
* 解析微信退分账响应或请求中的分金额。
*
* @param snapshot 微信退分账快照
* @return 分金额;无法解析时为空
*/
private Long parseWechatFzAmount(String snapshot) {
if (StrUtil.isBlank(snapshot)) return null;
try {
JSONObject object = JSONUtil.parseObj(snapshot);
Object response = findPath(object, "refund", "response");
Long amount = parseFenObject(response);
if (amount != null) return amount;
return parseFenObject(findFirstByKey(object, "amount"));
} catch (Exception exception) {
log.debug("第三方支付统计微信退分账快照解析失败,errorType:{}", exception.getClass().getSimpleName());
return null;
}
}
/**
* 解析支付宝退分账明细中的元金额并精确转换为分。
*
* @param snapshot 支付宝退分账请求或响应快照
* @return 分金额;无法解析时为空
*/
private Long parseAlipayRoyaltyAmount(String snapshot) {
if (StrUtil.isBlank(snapshot)) return null;
try {
Object root = JSONUtil.parse(snapshot);
List<Object> royaltyAmounts = new ArrayList<>();
collectAmountsUnderKey(root, Set.of("refundRoyaltys", "refundRoyaltyParameters"), royaltyAmounts);
// 历史请求直接保存退款分账明细数组,不带 refundRoyaltyParameters 外层字段。
if (royaltyAmounts.isEmpty() && root instanceof JSONArray) {
collectValuesByKey(root, "amount", royaltyAmounts);
}
if (royaltyAmounts.isEmpty()) return null;
long total = 0L;
for (Object value : royaltyAmounts) {
Long fen = yuanToFen(value);
if (fen == null) return null;
total = safeAdd(total, fen);
}
return total;
} catch (Exception exception) {
log.debug("第三方支付统计支付宝退分账快照解析失败,errorType:{}", exception.getClass().getSimpleName());
return null;
}
}
/**
* 按指定候选字段读取微信分金额。
*
* @param object JSON 对象
* @param keys 候选字段顺序
* @return 分金额;无有效字段时为空
*/
private Long parseFenCandidate(JSONObject object, Collection<String> keys) {
for (String key : keys) {
Long amount = parseFenObject(findFirstByKey(object, key));
if (amount != null) return amount;
}
return null;
}
/**
* 按指定候选字段读取支付宝元金额。
*
* @param object JSON 对象
* @param keys 候选字段顺序
* @return 分金额;无有效字段时为空
*/
private Long parseYuanCandidate(JSONObject object, Collection<String> keys) {
for (String key : keys) {
Long amount = yuanToFen(findFirstByKey(object, key));
if (amount != null) return amount;
}
return yuanToFen(findFirstByKey(object, "amount"));
}
/**
* 将微信候选对象(数字或含 refund 字段的 amount 对象)解析为分。
*
* @param value JSON 节点
* @return 分金额;无效或非正数时为空
*/
private Long parseFenObject(Object value) {
if (value instanceof JSONObject object) {
Object refund = findFirstByKey(object, "refund");
if (refund != null) return parseFenObject(refund);
Object amount = findFirstByKey(object, "amount");
if (amount != null) return parseFenObject(amount);
}
return positiveLong(value);
}
/**
* 把支付宝元金额用 BigDecimal 精确转换为分。
*
* @param value 元金额节点
* @return 分金额;格式错误时为空
*/
private Long yuanToFen(Object value) {
if (value == null) return null;
try {
BigDecimal yuan = new BigDecimal(String.valueOf(value).trim());
if (yuan.signum() <= 0) return null;
// 渠道金额只允许精确到分,出现更小单位时返回不可核对,不能静默四舍五入改变历史金额。
return yuan.movePointRight(2).longValueExact();
} catch (Exception exception) {
return null;
}
}
/**
* 将微信分金额节点解析为正整数分。
*
* @param value 分金额节点
* @return 分金额;格式错误时为空
*/
private Long positiveLong(Object value) {
if (value == null) return null;
try {
BigDecimal decimal = new BigDecimal(String.valueOf(value).trim());
if (decimal.signum() <= 0 || decimal.stripTrailingZeros().scale() > 0) return null;
return decimal.longValueExact();
} catch (Exception exception) {
return null;
}
}
/**
* 按路径读取嵌套 JSON 节点。
*
* @param root 根对象
* @param path 路径字段
* @return 节点;路径不存在时为空
*/
private Object findPath(Object root, String... path) {
Object current = root;
for (String part : path) {
if (!(current instanceof JSONObject object)) return null;
current = object.get(part);
}
return current;
}
/**
* 深度优先读取第一个指定字段,兼容微信 V2/V3 和渠道包装对象。
*
* @param root JSON 节点
* @param key 字段名
* @return 第一个字段值
*/
private Object findFirstByKey(Object root, String key) {
if (root instanceof JSONObject object) {
for (Map.Entry<String, Object> entry : object.entrySet()) {
if (entry.getKey().equalsIgnoreCase(key)) return entry.getValue();
}
for (Object value : object.values()) {
Object result = findFirstByKey(value, key);
if (result != null) return result;
}
} else if (root instanceof JSONArray array) {
for (Object value : array) {
Object result = findFirstByKey(value, key);
if (result != null) return result;
}
}
return null;
}
/**
* 收集指定字段下所有 amount 节点,用于多个支付宝退分账明细相加。
*
* @param root JSON 节点
* @param keys 需要展开的明细容器字段
* @param result 金额节点输出集合
*/
private void collectAmountsUnderKey(Object root, Set<String> keys, List<Object> result) {
if (root instanceof JSONObject object) {
for (Map.Entry<String, Object> entry : object.entrySet()) {
if (keys.stream().anyMatch(key -> key.equalsIgnoreCase(entry.getKey()))) {
collectValuesByKey(entry.getValue(), "amount", result);
}
collectAmountsUnderKey(entry.getValue(), keys, result);
}
} else if (root instanceof JSONArray array) {
for (Object value : array) collectAmountsUnderKey(value, keys, result);
}
}
/**
* 深度优先收集指定字段值。
*
* @param root JSON 节点
* @param key 字段名
* @param result 值输出集合
*/
private void collectValuesByKey(Object root, String key, List<Object> result) {
if (root instanceof JSONObject object) {
for (Map.Entry<String, Object> entry : object.entrySet()) {
if (entry.getKey().equalsIgnoreCase(key)) result.add(entry.getValue());
collectValuesByKey(entry.getValue(), key, result);
}
} else if (root instanceof JSONArray array) {
for (Object value : array) collectValuesByKey(value, key, result);
}
}
/**
* 将空数值转换为零,统一保护历史 NULL 聚合结果。
*
* @param value 可能为空的数值
* @return 非空数值或零
*/
private long zeroIfNull(Long value) {
return value == null ? 0L : value;
}
/**
* 对金额和次数执行溢出检查,避免 Long 溢出静默污染统计结果。
*
* @param left 左值
* @param right 右值
* @return 精确和
*/
private long safeAdd(long left, long right) {
return Math.addExact(left, right);
}
}
@@ -0,0 +1,24 @@
package com.bsd.pjcloud.thirdparty.statistics;
import lombok.Data;
/**
* SQL 聚合阶段的商户与支付方式金额结果。
*
* <p>该对象只承载已经在数据库中按维度聚合的成功记录,金额单位为分,避免把大批成功订单完整加载到 JVM。</p>
*/
@Data
public class ThirdPayTotalAggregateRecord {
/** 商户主键,历史订单缺失时允许为空。 */
private Long mchId;
/** 支付平台类型编码,未知编码原样传递。 */
private String payPlatformType;
/** 当前聚合维度的记录数量。 */
private Long recordCount;
/** 当前聚合维度的金额,单位分。 */
private Long amount;
}
@@ -0,0 +1,28 @@
package com.bsd.pjcloud.thirdparty.statistics;
import lombok.Data;
/**
* 成功退分账记录及其历史快照。
*/
@Data
public class ThirdPayTotalFzRefundRecord {
/** 分账记录主键,用于明确每一条成功退分账事实。 */
private Long id;
/** 商户主键,来源于关联支付订单。 */
private Long mchId;
/** 支付平台类型编码,来源于关联支付订单。 */
private String payPlatformType;
/** 支付订单号,跨租户关联时与 tenant_id 一起作为连接条件。 */
private String payNo;
/** 微信或支付宝发起退分账请求快照。 */
private String fzRefundReq;
/** 微信或支付宝退分账响应快照。 */
private String fzRefundRes;
}
@@ -0,0 +1,31 @@
package com.bsd.pjcloud.thirdparty.statistics;
import lombok.Data;
/**
* 支付退款成功记录及其历史快照。
*/
@Data
public class ThirdPayTotalRefundRecord {
/** 支付订单主键,用于退款订单去重。 */
private Long id;
/** 商户主键。 */
private Long mchId;
/** 支付平台类型编码。 */
private String payPlatformType;
/** 支付订单号,便于诊断历史数据。 */
private String payNo;
/** 最终退款回调快照,金额解析优先使用。 */
private String refundCallbackRes;
/** 渠道退款响应快照,回调缺失时使用。 */
private String refundRes;
/** 发起退款请求快照,响应缺失时使用。 */
private String refundRep;
}
@@ -0,0 +1,22 @@
package com.bsd.pjcloud.thirdparty.statistics;
/**
* 统计维度键,使用商户和支付方式组成稳定的内存聚合键。
*
* @param mchId 商户主键,历史订单未记录商户时允许为空
* @param payPlatformType 支付平台类型编码,未知历史编码原样保留
*/
public record ThirdPayTotalStatisticsSupport(Long mchId, String payPlatformType) {
/**
* 创建统计维度键并规范空支付方式,确保数据库 NULL 与空字符串不会产生重复维度。
*
* @param mchId 商户主键
* @param payPlatformType 支付方式编码
* @return 规范后的统计维度键
*/
public static ThirdPayTotalStatisticsSupport of(Long mchId, String payPlatformType) {
return new ThirdPayTotalStatisticsSupport(mchId, payPlatformType == null || payPlatformType.isBlank() ? null : payPlatformType);
}
}
@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayTotalMapper">
<sql id="payBaseWhere">
p.del_flag = '0'
and p.pay_time between #{query.payTimeList[0]} and #{query.payTimeList[1]}
<if test="query.mchId != null">
and p.mch_id = #{query.mchId}
</if>
<if test="query.payPlatformType != null and query.payPlatformType != ''">
and p.pay_platform_type = #{query.payPlatformType}
</if>
</sql>
<select id="selectPaySuccessAggregates"
resultType="com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalAggregateRecord">
select p.mch_id as mchId,
p.pay_platform_type as payPlatformType,
count(distinct p.id) as recordCount,
coalesce(sum(p.price), 0) as amount
from third_pay_order p
<where>
<include refid="payBaseWhere"/>
and p.status = '20'
</where>
group by p.mch_id, p.pay_platform_type
</select>
<select id="selectRefundRecords"
resultType="com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalRefundRecord">
select distinct p.id,
p.mch_id as mchId,
p.pay_platform_type as payPlatformType,
p.pay_no as payNo,
p.refund_callback_res as refundCallbackRes,
p.refund_res as refundRes,
p.refund_rep as refundRep
from third_pay_order p
<where>
<include refid="payBaseWhere"/>
and p.status = '200'
</where>
</select>
<select id="selectFzSuccessAggregates"
resultType="com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalAggregateRecord">
select base.mch_id as mchId,
base.pay_platform_type as payPlatformType,
coalesce(sum(fz.record_count), 0) as recordCount,
coalesce(sum(fz.amount), 0) as amount
from (
select p.tenant_id, p.pay_no, p.mch_id, p.pay_platform_type
from third_pay_order p
<where>
<include refid="payBaseWhere"/>
and p.pay_no is not null
</where>
group by p.tenant_id, p.pay_no, p.mch_id, p.pay_platform_type
) base
inner join (
select f.tenant_id,
f.pay_no,
count(*) as record_count,
coalesce(sum(f.price), 0) as amount
from third_fz_order f
where f.del_flag = '0'
and f.status = '20'
and f.pay_no is not null
group by f.tenant_id, f.pay_no
) fz on fz.tenant_id = base.tenant_id and fz.pay_no = base.pay_no
group by base.mch_id, base.pay_platform_type
</select>
<select id="selectFzRefundRecords"
resultType="com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalFzRefundRecord">
select f.id,
base.mch_id as mchId,
base.pay_platform_type as payPlatformType,
f.pay_no as payNo,
f.fz_refund_req as fzRefundReq,
f.fz_refund_res as fzRefundRes
from (
select p.tenant_id, p.pay_no, p.mch_id, p.pay_platform_type
from third_pay_order p
<where>
<include refid="payBaseWhere"/>
and p.pay_no is not null
</where>
group by p.tenant_id, p.pay_no, p.mch_id, p.pay_platform_type
) base
inner join third_fz_order f
on f.tenant_id = base.tenant_id
and f.pay_no = base.pay_no
and f.del_flag = '0'
and f.status = '200'
</select>
</mapper>
@@ -0,0 +1,117 @@
package com.bsd.pjcloud.thirdparty.mapper.pay;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
/**
* 第三方支付统计 SQL 契约测试,防止后续维护引入支付与分账扇出或移除租户连接条件。
*/
class ThirdPayTotalMapperSqlContractTest {
/** 第三方支付统计 MyBatis 映射资源路径。 */
private static final String MAPPER_RESOURCE = "mapper/ThirdPayTotalMapper.xml";
/** 测试启动时读取的统计 Mapper XML。 */
private static String mapperXml;
/**
* 从测试类路径读取本轮实际参与编译的 Mapper XML。
*
* @throws IOException 资源读取失败时抛出
*/
@BeforeAll
static void loadMapperXml() throws IOException {
try (InputStream input = ThirdPayTotalMapperSqlContractTest.class.getClassLoader()
.getResourceAsStream(MAPPER_RESOURCE)) {
Assertions.assertNotNull(input, "第三方支付统计 Mapper XML 必须进入运行时类路径");
mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
}
/**
* 支付成功聚合不得连接分账表,避免一个支付订单因多条分账记录重复累加金额。
*/
@Test
void paySuccessAggregateShouldNotJoinProfitSharingRows() {
String sql = statement("selectPaySuccessAggregates");
Assertions.assertAll(
() -> Assertions.assertTrue(sql.contains("count(distinct p.id)")),
() -> Assertions.assertTrue(sql.contains("p.status = '20'")),
() -> Assertions.assertFalse(sql.contains("third_fz_order")));
}
/**
* 退款快照必须独立读取并在 Service 解析,不允许 SQL 展开 JSON 后制造重复退款事实。
*/
@Test
void refundRecordsShouldRemainIndependentFromJsonExpansion() {
String sql = statement("selectRefundRecords");
Assertions.assertAll(
() -> Assertions.assertTrue(sql.contains("select distinct p.id")),
() -> Assertions.assertTrue(sql.contains("p.status = '200'")),
() -> Assertions.assertFalse(sql.toLowerCase().contains("json_extract")),
() -> Assertions.assertFalse(sql.toLowerCase().contains("json_table")));
}
/**
* 分账聚合必须先按租户和支付单号收敛,再通过相同二元键连接支付基础集合。
*/
@Test
void profitSharingAggregateShouldJoinByTenantAndPayNoAfterGrouping() {
String sql = statement("selectFzSuccessAggregates");
Assertions.assertAll(
() -> Assertions.assertTrue(sql.contains("group by f.tenant_id, f.pay_no")),
() -> Assertions.assertTrue(sql.contains("fz.tenant_id = base.tenant_id")),
() -> Assertions.assertTrue(sql.contains("fz.pay_no = base.pay_no")),
() -> Assertions.assertTrue(sql.contains("f.status = '20'")),
() -> Assertions.assertTrue(sql.contains("f.del_flag = '0'")));
}
/**
* 成功退分账查询同样必须限制租户、支付单号、软删除和状态,不能跨租户串单。
*/
@Test
void profitSharingRefundShouldKeepTenantAndSoftDeleteConditions() {
String sql = statement("selectFzRefundRecords");
Assertions.assertAll(
() -> Assertions.assertTrue(sql.contains("f.tenant_id = base.tenant_id")),
() -> Assertions.assertTrue(sql.contains("f.pay_no = base.pay_no")),
() -> Assertions.assertTrue(sql.contains("f.del_flag = '0'")),
() -> Assertions.assertTrue(sql.contains("f.status = '200'")));
}
/**
* 全部查询必须复用支付时间、商户、支付方式和支付订单软删除条件。
*/
@Test
void baseWhereShouldKeepRequiredFilters() {
Assertions.assertAll(
() -> Assertions.assertTrue(mapperXml.contains("p.del_flag = '0'")),
() -> Assertions.assertTrue(mapperXml.contains("p.pay_time between #{query.payTimeList[0]} and #{query.payTimeList[1]}")),
() -> Assertions.assertTrue(mapperXml.contains("p.mch_id = #{query.mchId}")),
() -> Assertions.assertTrue(mapperXml.contains("p.pay_platform_type = #{query.payPlatformType}")));
}
/**
* 提取指定 ID 的 select 片段,兼容元素属性换行。
*
* @param id MyBatis 查询 ID
* @return 对应查询 XML 片段
*/
private static String statement(String id) {
int idIndex = mapperXml.indexOf("id=\"" + id + "\"");
int start = mapperXml.lastIndexOf("<select", idIndex);
int end = mapperXml.indexOf("</select>", idIndex);
Assertions.assertTrue(idIndex >= 0 && start >= 0 && end > idIndex, "缺少 MyBatis 查询:" + id);
return mapperXml.substring(start, end);
}
}
@@ -0,0 +1,254 @@
package com.bsd.pjcloud.thirdparty.service.impl.pay;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayTotalQueryDto;
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayTotalMapper;
import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalAggregateRecord;
import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalFzRefundRecord;
import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalRefundRecord;
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalRowVo;
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalStatisticsVo;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.Mockito.when;
/**
* 第三方支付统计服务测试,覆盖历史快照解析、范围校验、聚合完整性和商户服务降级。
*/
@ExtendWith(MockitoExtension.class)
class ThirdPayTotalServiceImplTest {
/** 支付统计 Mapper Mock,分别返回支付、退款、分账和退分账事实。 */
@Mock
private ThirdPayTotalMapper thirdPayTotalMapper;
/** 商户远程服务 Mock,用于验证名称补充和异常降级。 */
@Mock
private RemoteMchService remoteMchService;
/** 被测第三方支付统计服务。 */
@InjectMocks
private ThirdPayTotalServiceImpl service;
/**
* 支付时间必须完整、正序且跨度不超过一年,防止空条件扫描全历史数据。
*/
@Test
void validateQueryShouldRejectInvalidPayTimeRange() {
ThirdPayTotalQueryDto missing = new ThirdPayTotalQueryDto();
ThirdPayTotalQueryDto incomplete = new ThirdPayTotalQueryDto();
incomplete.setPayTimeList(new LocalDateTime[]{LocalDateTime.of(2026, 1, 1, 0, 0), null});
ThirdPayTotalQueryDto reversed = query(LocalDateTime.of(2026, 2, 1, 0, 0),
LocalDateTime.of(2026, 1, 1, 0, 0));
ThirdPayTotalQueryDto overOneYear = query(LocalDateTime.of(2025, 1, 1, 0, 0),
LocalDateTime.of(2026, 1, 1, 0, 0, 1));
ThirdPayTotalQueryDto exactlyOneYear = query(LocalDateTime.of(2025, 1, 1, 0, 0),
LocalDateTime.of(2026, 1, 1, 0, 0));
Assertions.assertAll(
() -> Assertions.assertThrows(IllegalArgumentException.class, () -> service.validateQuery(null)),
() -> Assertions.assertThrows(IllegalArgumentException.class, () -> service.validateQuery(missing)),
() -> Assertions.assertThrows(IllegalArgumentException.class, () -> service.validateQuery(incomplete)),
() -> Assertions.assertThrows(IllegalArgumentException.class, () -> service.validateQuery(reversed)),
() -> Assertions.assertThrows(IllegalArgumentException.class, () -> service.validateQuery(overOneYear)),
() -> Assertions.assertDoesNotThrow(() -> service.validateQuery(exactlyOneYear)));
}
/**
* 微信退款应兼容 V2 字段、V3 amount.refund,并严格遵循回调优先于响应和请求的顺序。
*/
@Test
void parseRefundAmountShouldSupportWechatV2AndV3Snapshots() {
ThirdPayTotalRefundRecord v2 = refundRecord("10");
v2.setRefundCallbackRes("{\"refund_fee\":\"125\"}");
v2.setRefundRes("{\"refund_fee\":999}");
ThirdPayTotalRefundRecord v3 = refundRecord("10");
v3.setRefundRes("{\"amount\":{\"total\":1000,\"refund\":234}}");
Assertions.assertEquals(125L, service.parseRefundAmount(v2));
Assertions.assertEquals(234L, service.parseRefundAmount(v3));
}
/**
* 支付宝退款金额按元使用 BigDecimal 精确转分,超过分精度或损坏快照必须标记为不可解析。
*/
@Test
void parseRefundAmountShouldConvertAlipayYuanPrecisely() {
ThirdPayTotalRefundRecord precise = refundRecord("20");
precise.setRefundCallbackRes("{\"refundAmount\":\"12.34\"}");
ThirdPayTotalRefundRecord detail = refundRecord("20");
detail.setRefundRes("{\"refund_detail_item_list\":[{\"amount\":\"0.07\"}]}");
ThirdPayTotalRefundRecord subFen = refundRecord("20");
subFen.setRefundRep("{\"refundAmount\":\"1.005\"}");
ThirdPayTotalRefundRecord invalid = refundRecord("20");
invalid.setRefundRep("not-json");
Assertions.assertAll(
() -> Assertions.assertEquals(1234L, service.parseRefundAmount(precise)),
() -> Assertions.assertEquals(7L, service.parseRefundAmount(detail)),
() -> Assertions.assertNull(service.parseRefundAmount(subFen)),
() -> Assertions.assertNull(service.parseRefundAmount(invalid)));
}
/**
* 退分账解析应兼容微信结构化响应和支付宝多条分账退款明细,且不能读取普通整单退款金额。
*/
@Test
void parseFzRefundAmountShouldReadOnlyRoyaltyRefundAmounts() {
ThirdPayTotalFzRefundRecord wechat = fzRefundRecord("10");
wechat.setFzRefundRes("{\"refund\":{\"response\":{\"amount\":300}}}");
ThirdPayTotalFzRefundRecord alipayRequest = fzRefundRecord("20");
alipayRequest.setFzRefundReq("[{\"amount\":\"1.20\"},{\"amount\":\"0.03\"}]");
ThirdPayTotalFzRefundRecord alipayResponse = fzRefundRecord("20");
alipayResponse.setFzRefundRes("{\"response\":{\"refundRoyaltys\":[{\"amount\":\"2.50\"}]}}");
ThirdPayTotalFzRefundRecord unrelatedAmount = fzRefundRecord("20");
unrelatedAmount.setFzRefundRes("{\"refundAmount\":\"10.00\",\"amount\":\"10.00\"}");
Assertions.assertAll(
() -> Assertions.assertEquals(300L, service.parseFzRefundAmount(wechat)),
() -> Assertions.assertEquals(123L, service.parseFzRefundAmount(alipayRequest)),
() -> Assertions.assertEquals(250L, service.parseFzRefundAmount(alipayResponse)),
() -> Assertions.assertNull(service.parseFzRefundAmount(unrelatedAmount)));
}
/**
* 四类事实应在商户与支付方式维度合并,未知退分账金额保留次数并使净收款标为部分可核对。
*/
@Test
void statisticsShouldMergeIndependentFactsAndMarkIncompleteNetAmount() {
ThirdPayTotalQueryDto query = validQuery();
when(thirdPayTotalMapper.selectPaySuccessAggregates(query)).thenReturn(List.of(aggregate(1L, "10", 2L, 1000L)));
ThirdPayTotalRefundRecord parsedRefund = refundRecord("10");
parsedRefund.setMchId(1L);
parsedRefund.setRefundCallbackRes("{\"amount\":{\"refund\":200}}");
ThirdPayTotalRefundRecord unknownRefund = refundRecord("10");
unknownRefund.setMchId(1L);
when(thirdPayTotalMapper.selectRefundRecords(query)).thenReturn(List.of(parsedRefund, unknownRefund));
when(thirdPayTotalMapper.selectFzSuccessAggregates(query)).thenReturn(List.of(aggregate(1L, "10", 2L, 300L)));
ThirdPayTotalFzRefundRecord parsedFzRefund = fzRefundRecord("10");
parsedFzRefund.setMchId(1L);
parsedFzRefund.setFzRefundReq("{\"amount\":100}");
ThirdPayTotalFzRefundRecord unknownFzRefund = fzRefundRecord("10");
unknownFzRefund.setMchId(1L);
when(thirdPayTotalMapper.selectFzRefundRecords(query)).thenReturn(List.of(parsedFzRefund, unknownFzRefund));
MchManageVo merchant = new MchManageVo();
merchant.setId(1L);
merchant.setName("测试商户");
when(remoteMchService.getByIds(List.of(1L))).thenReturn(R.ok(List.of(merchant)));
ThirdPayTotalStatisticsVo result = service.statistics(query);
ThirdPayTotalRowVo row = result.getRows().get(0);
Assertions.assertAll(
() -> Assertions.assertEquals("测试商户", row.getMchName()),
() -> Assertions.assertEquals(2L, row.getPayOrderCount()),
() -> Assertions.assertEquals(1000L, row.getPayAmount()),
() -> Assertions.assertEquals(2L, row.getRefundOrderCount()),
() -> Assertions.assertEquals(200L, row.getRefundAmount()),
() -> Assertions.assertEquals(1L, row.getRefundAmountUnavailableCount()),
() -> Assertions.assertEquals(2L, row.getFzSuccessCount()),
() -> Assertions.assertEquals(300L, row.getFzSuccessAmount()),
() -> Assertions.assertEquals(2L, row.getFzRefundSuccessCount()),
() -> Assertions.assertEquals(100L, row.getFzRefundSuccessAmount()),
() -> Assertions.assertEquals(1L, row.getFzRefundAmountUnavailableCount()),
() -> Assertions.assertEquals(200L, row.getPlatformNetAmount()),
() -> Assertions.assertFalse(row.isPlatformNetAmountComplete()),
() -> Assertions.assertEquals(row.getPlatformNetAmount(), result.getTotal().getPlatformNetAmount()),
() -> Assertions.assertFalse(result.getTotal().isPlatformNetAmountComplete()));
}
/**
* 商户远程调用异常时应保留原商户 ID 和未知支付方式编码,避免历史统计行静默丢失。
*/
@Test
void statisticsShouldKeepRowsWhenMerchantServiceFails() {
ThirdPayTotalQueryDto query = validQuery();
when(thirdPayTotalMapper.selectPaySuccessAggregates(query)).thenReturn(List.of(aggregate(9L, "99", 1L, 500L)));
when(thirdPayTotalMapper.selectRefundRecords(query)).thenReturn(List.of());
when(thirdPayTotalMapper.selectFzSuccessAggregates(query)).thenReturn(List.of());
when(thirdPayTotalMapper.selectFzRefundRecords(query)).thenReturn(List.of());
when(remoteMchService.getByIds(List.of(9L))).thenThrow(new IllegalStateException("remote unavailable"));
ThirdPayTotalRowVo row = service.statistics(query).getRows().get(0);
Assertions.assertAll(
() -> Assertions.assertEquals(9L, row.getMchId()),
() -> Assertions.assertEquals("商户信息不可用", row.getMchName()),
() -> Assertions.assertEquals("99", row.getPayPlatformType()),
() -> Assertions.assertEquals(500L, row.getPayAmount()));
}
/**
* 构造合法的最近统计范围。
*
* @return 包含完整时间范围的查询条件
*/
private ThirdPayTotalQueryDto validQuery() {
return query(LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 31, 23, 59, 59));
}
/**
* 构造指定开始和结束时间的统计查询。
*
* @param start 支付开始时间
* @param end 支付结束时间
* @return 支付统计查询条件
*/
private ThirdPayTotalQueryDto query(LocalDateTime start, LocalDateTime end) {
ThirdPayTotalQueryDto query = new ThirdPayTotalQueryDto();
query.setPayTimeList(new LocalDateTime[]{start, end});
return query;
}
/**
* 构造 SQL 聚合返回值。
*
* @param mchId 商户主键
* @param platform 支付平台编码
* @param count 记录数
* @param amount 金额,单位分
* @return 聚合记录
*/
private ThirdPayTotalAggregateRecord aggregate(Long mchId, String platform, Long count, Long amount) {
ThirdPayTotalAggregateRecord record = new ThirdPayTotalAggregateRecord();
record.setMchId(mchId);
record.setPayPlatformType(platform);
record.setRecordCount(count);
record.setAmount(amount);
return record;
}
/**
* 构造支付退款快照记录。
*
* @param platform 支付平台编码
* @return 退款记录
*/
private ThirdPayTotalRefundRecord refundRecord(String platform) {
ThirdPayTotalRefundRecord record = new ThirdPayTotalRefundRecord();
record.setPayPlatformType(platform);
return record;
}
/**
* 构造退分账快照记录。
*
* @param platform 支付平台编码
* @return 退分账记录
*/
private ThirdPayTotalFzRefundRecord fzRefundRecord(String platform) {
ThirdPayTotalFzRefundRecord record = new ThirdPayTotalFzRefundRecord();
record.setPayPlatformType(platform);
return record;
}
}