feat:购买商品统计

This commit is contained in:
dzc committed 2026-09-15 17:42:30 +08:00
1 parent bc2c7e2b06
commit 7d9c29e247
12 files changed
+1912

No files matched your search

+3
View File
@@ -62,6 +62,9 @@ gen_code
### node ###
node_modules
### 充值导入测试脚本(含 Excel 原始数据与本地配置) ###
scripts/recharge-import/
### AI 辅助工具 ###
.video_agent
.mimosa
@@ -0,0 +1,28 @@
-- 购物车商品记录页(/hard/hard-car-record)查询索引。
-- 幂等迁移脚本:可反复执行。索引先查 information_schema,已存在则跳过(执行 SELECT 1 占位),
-- 不存在才执行 DDL;已应用过的库重跑不会报 Duplicate key。
-- 背景:记录页与导出按 hard_car a JOIN hard_parent_car p(a.parent_car_id = p.id)
-- JOIN hard_order o(o.parent_car_id = p.id)关联,hard_order.parent_car_id 此前无索引,
-- 量级增长后三表 HASH JOIN 全表扫描会成为列表与导出的共同瓶颈。
-- 【可选诊断】两表 parent_car_id 现有索引。
SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') AS index_columns
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware'
AND TABLE_NAME IN ('hard_car', 'hard_order', 'hard_parent_car')
AND COLUMN_NAME = 'parent_car_id'
GROUP BY TABLE_NAME, INDEX_NAME;
-- hard_order 关联索引:缺失才创建(含租户与删除标记,与既有组合索引风格一致)。
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_order` ADD KEY `idx_hard_order_parent_tenant_del` (`parent_car_id`, `tenant_id`, `del_flag`)',
'SELECT 1')
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order' AND INDEX_NAME = 'idx_hard_order_parent_tenant_del');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- hard_parent_car 主键已覆盖 p.id 关联,无需新增;hard_car 侧已有
-- idx_hard_car_parent_tenant_del(parent_car_id, tenant_id, del_flag),无需新增。
-- 以上判断依据上方诊断查询输出,重跑时可复核。
@@ -0,0 +1,147 @@
package com.bsd.pjcloud.hard.api.dto.order;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 购物车商品记录管理端查询条件,以购物车商品明细为查询维度。
*
* <p>记录页一行对应一条购物车商品快照,商品条件作用于 hard_car,其余订单、用户、部门和时间条件
* 均通过关联的订单表过滤;身份条件交由 UPMS 用户接口转换为用户主键范围。</p>
*/
@Data
public class HardCarRecordDto implements BaseUserIdentityQueryInterface {
/**
* 商品主键筛选;来源于购物车快照 product_id,为空表示不限定商品。
*/
private Long productId;
/**
* 商品名称模糊筛选;作用于购物车快照的商品名称。
*/
private String productName;
/**
* 购物车商品行主键集合;列表勾选导出时优先限定这些明细行。
*/
private List<Long> carIds;
/**
* 订单业务号精确筛选。
*/
private String orderNo;
/**
* 订单类型协议编码;页面订单类型 Tab 使用单值,为空表示全部类型。
*/
private String orderType;
/**
* 订单状态协议编码集合;为空表示不限制订单状态。
*/
private List<String> orderStatusCodes;
/**
* 支付方式协议编码集合;为空表示不限制支付方式。
*/
private List<String> orderPayTypeCodes;
/**
* 用餐时间段协议编码集合;为空表示不限制餐段。
*/
private List<String> hourTypeCodes;
/**
* 页面选择的商户类型编码集合;与服务端注入的设备类型权限条件分别保留,最终按交集过滤。
*/
private List<String> merchantTypeCodes;
/**
* 服务端按当前用户设备类型权限注入的商户类型编码集合;与页面多选条件按交集过滤。
*/
private List<String> mchTypeCodes;
/**
* 设备类型协议编码集合;为空表示不限制设备类型。
*/
private List<String> hardTypeCodes;
/**
* 商户主键筛选;与商户多选条件叠加。
*/
private Long merchantId;
/**
* 商户主键集合;单商户时服务端会展开为自身加子商户范围。
*/
private List<Long> mchIds;
/**
* 单个购买用户主键筛选。
*/
private Long userId;
/**
* 购买用户主键集合;身份筛选转换结果也写入该字段。
*/
private List<Long> userIds;
/**
* 用户部门主键筛选(下拉选择部门)。
*/
private Long userDeptId;
/**
* 用户部门主键集合(左侧部门树勾选)。
*/
private List<Long> userDeptIds;
/**
* 部门树勾选是否包含子部门;控制 userDeptIds 使用 IN 或 NOT IN。
*/
private Boolean isInclude;
/**
* 订单下单时间范围。
*/
private LocalDateTime[] createTimeList;
/**
* 订单支付时间范围。
*/
private LocalDateTime[] payTimeList;
/**
* 订单核销时间范围。
*/
private LocalDateTime[] verificationTimeList;
/**
* 预计使用日期范围。
*/
private LocalDateTime[] apptUseDateTimeList;
/**
* 用户身份协议编码筛选集合;查询前转换为用户主键范围。
*/
private List<String> identityTypes;
/**
* 学生身份扩展筛选条件。
*/
private com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto studentInfo;
/**
* 老师身份扩展筛选条件。
*/
private com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto teacherInfo;
/**
* 人脸录入状态筛选条件。
*/
private String hasFace;
}
@@ -0,0 +1,290 @@
package com.bsd.pjcloud.hard.api.vo.order;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.wallet.api.vo.base.BaseMchInterface;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
* 购物车商品记录管理端展示对象,一行对应一条购物车商品快照及其所属订单摘要。
*
* <p>商品列来源于 hard_car 快照,订单列通过父购物车关联 hard_order 得到;用户、身份和商户展示资料
* 由服务层批量装配。实现用户部门与身份展示契约后可直接复用通用身份装配器和前端身份组件。</p>
*/
@Data
public class HardCarRecordVo implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseMchInterface {
/**
* 购物车商品行主键,即记录页的行主键。
*/
private Long id;
/**
* 父购物车主键,来源于 hard_car.parent_car_id。
*/
private Long parentCarId;
/* ************************************* 商品快照 ************************************* */
/**
* 商品主键,来源于购物车快照。
*/
private Long productId;
/**
* 商品来源详情 ID;每日菜谱等来源指向对应业务记录。
*/
private Long sourceProductId;
/**
* 商品名称快照。
*/
private String productName;
/**
* 商品图片快照。
*/
private String productPic;
/**
* 商品单价快照,单位为分。
*/
private Integer productPrice;
/**
* 商品性质协议编码。
*/
private String productNature;
/**
* 购买数量。
*/
private Integer productCount;
/**
* 商品重量。
*/
private java.math.BigDecimal productWeight;
/**
* 商品小计金额,单位为分。
*/
private Long totalPrice;
/**
* 购物车行创建时间。
*/
private LocalDateTime carCreateTime;
/* ************************************* 订单摘要 ************************************* */
/**
* 所属订单主键。
*/
private Long orderId;
/**
* 所属订单业务号。
*/
private String orderNo;
/**
* 订单类型协议编码。
*/
private String orderType;
/**
* 订单状态协议编码。
*/
private String orderStatus;
/**
* 支付方式协议编码。
*/
private String payType;
/**
* 用餐时间段协议编码。
*/
private String hourType;
/**
* 预约目标用餐日期。
*/
private LocalDate apptUseDate;
/**
* 核销时间。
*/
private LocalDateTime verificationTime;
/**
* 核销方式协议编码;'2' 表示自动核销。
*/
private String verificationType;
/**
* 订单下单时间。
*/
private LocalDateTime orderCreateTime;
/**
* 订单支付时间。
*/
private LocalDateTime payTime;
/**
* 订单商品总价,单位为分。
*/
private Integer orderTotalPrice;
/**
* 额外支付金额,单位为分。
*/
private Integer extraPrice;
/**
* 配送费金额,单位为分。
*/
private Integer deliveryPrice;
/**
* 订单实际支付金额,单位为分。
*/
private Integer payPrice;
/**
* 订单已退款金额,单位为分。
*/
private Integer refundPrice;
/**
* 退款状态协议编码。
*/
private String refundStatus;
/**
* 最近退款完成时间。
*/
private LocalDateTime refundTime;
/**
* 订单关联钱包主键。
*/
private Long walletId;
/**
* 付款人用户 ID,来源于订单关联的父购物车。
*/
private Long payerUserId;
/**
* 业务系统类型协议编码。
*/
private String sysType;
/**
* 是否线下补录订单协议编码;空值按非线下处理。
*/
private String isOfflineOrder;
/* ************************************* 商户与设备 ************************************* */
/**
* 商户主键。
*/
private Long merchantId;
/**
* 商户类型协议编码。
*/
private String merchantType;
/**
* 商户所属部门主键;数据权限拦截器依赖该列包装部门过滤。
*/
private Long merchantDeptId;
/**
* 设备主键。
*/
private Long hardId;
/**
* 设备名称,来源于设备表。
*/
private String hardName;
/**
* 设备类型协议编码,来源于设备表。
*/
private String hardType;
/**
* 商户名称,服务层批量装配。
*/
private String merchantName;
/**
* 商户 Logo 图片地址,服务层批量装配。
*/
private String logoPic;
/**
* 钱包类型唯一编码,服务层批量装配。
*/
private String walletTypeUniqueCode;
/* ************************************* 用户与身份展示 ************************************* */
/**
* 购买用户主键。
*/
private Long userId;
/**
* 用户姓名,UPMS 装配结果。
*/
private String username;
/**
* 用户手机号,UPMS 装配结果。
*/
private String userMobile;
/**
* 用户头像地址,UPMS 装配结果。
*/
private String userAvatar;
/**
* 用户部门主键,UPMS 装配结果。
*/
private Long userDeptId;
/**
* 用户部门名称,UPMS 装配结果。
*/
private String userDeptName;
/**
* 用户身份协议编码集合,UPMS 装配结果。
*/
private List<String> identityTypes;
/**
* 学生身份扩展资料,UPMS 装配结果。
*/
private SysUserStudentDto studentInfo;
/**
* 老师身份扩展资料,UPMS 装配结果。
*/
private SysUserTeacherDto teacherInfo;
}
@@ -0,0 +1,148 @@
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;
/**
* 购物车商品记录导出行,按商品业务域组织二级表头。
*
* <p>一行对应一条购物车商品快照及其所属订单摘要,仅保留业务核对所需的商品、用户、身份、
* 订单和时间信息,隐藏内部主键及审计字段;金额字段统一使用元。该对象仅用于导出展示,
* 不参与数据库持久化和接口入参。</p>
*/
@Data
@ColumnWidth(15)
@HeadRowHeight(15)
@HeadFontStyle(fontHeightInPoints = 10)
@ContentFontStyle(fontHeightInPoints = 10)
public class HardCarRecordExportExcelVo {
/** 当前商品记录在本次导出结果中的一基序号;合计行为空。 */
@ExcelProperty({"商品", "序号"})
private String lineNumber;
/** 购物车快照商品名称。 */
@ExcelProperty({"商品", "商品名称"})
@ColumnWidth(22)
private String productName;
/** 商品单价,单位为元并保留两位小数。 */
@ExcelProperty({"商品", "单价(元)"})
@NumberFormat("#,##0.00")
private BigDecimal productPrice;
/** 购买数量。 */
@ExcelProperty({"商品", "数量"})
private Integer productCount;
/** 商品小计金额,单位为元并保留两位小数。 */
@ExcelProperty({"商品", "小计(元)"})
@NumberFormat("#,##0.00")
private BigDecimal totalPrice;
/** 用户姓名,优先取 UPMS 姓名,其次取昵称、账号和历史快照。 */
@ExcelProperty({"用户", "姓名"})
private String userName;
/** 用户手机号;UPMS 资料缺失时回退历史快照。 */
@ExcelProperty({"用户", "手机号"})
private String phone;
/** 用户当前所属部门名称;UPMS 资料缺失时回退历史快照。 */
@ExcelProperty({"用户", "部门"})
private String deptName;
/** 用户拥有的身份中文名称,多个身份使用中文逗号分隔。 */
@ExcelProperty({"身份", "身份类型"})
private String identityTypeNames;
/** 学生身份学号;没有学生身份时为空。 */
@ExcelProperty({"身份", "学号"})
private String studentNo;
/** 学生身份学校名称;没有学生身份时为空。 */
@ExcelProperty({"身份", "学校"})
private String schoolName;
/** 学生身份班级名称;没有学生身份时为空。 */
@ExcelProperty({"身份", "班级"})
private String className;
/** 老师身份工号或学号;没有老师身份时为空。 */
@ExcelProperty({"身份", "老师学号"})
private String teacherNo;
/** 订单业务号。 */
@ExcelProperty({"订单", "订单号"})
@ColumnWidth(24)
private String orderNo;
/** 订单类型中文名称;未知历史编码保留原始值。 */
@ExcelProperty({"订单", "订单类型"})
private String orderType;
/** 订单状态中文名称;未知历史编码保留原始值。 */
@ExcelProperty({"订单", "订单状态"})
private String orderStatus;
/** 钱包服务返回的商户名称;历史商户不存在时回退快照名称。 */
@ExcelProperty({"订单", "商户"})
private String merchantName;
/** 订单关联设备名称;用于识别核销机器。 */
@ExcelProperty({"订单", "设备"})
private String hardName;
/** 实际关联设备的设备类型中文名称;未知历史编码保留原始值。 */
@ExcelProperty({"订单", "设备类型"})
private String hardType;
/** 支付方式中文名称;未知历史编码保留原始值。 */
@ExcelProperty({"订单", "支付方式"})
private String payType;
/** 用餐时间段中文名称;未知历史编码保留原始值。 */
@ExcelProperty({"订单", "用餐时段"})
private String hourType;
/** 订单创建时间,格式为 yyyy-MM-dd HH:mm:ss。 */
@ExcelProperty({"时间", "下单时间"})
@ColumnWidth(22)
private String createTime;
/** 支付完成时间,格式为 yyyy-MM-dd HH:mm:ss;未支付时为空。 */
@ExcelProperty({"时间", "支付时间"})
@ColumnWidth(22)
private String payTime;
/** 预约目标用餐日期;非预约订单可能为下单日期或空值。 */
@ExcelProperty({"时间", "预约时间"})
@ColumnWidth(22)
private String apptUseDate;
/** 订单实际核销完成时间,格式为 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;
}
@@ -0,0 +1,109 @@
package com.bsd.pjcloud.hard.controller.order;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
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.data.datascope.DataScopeFuncEnum;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.hard.api.dto.order.HardCarRecordDto;
import com.bsd.pjcloud.hard.service.HardCarRecordExportService;
import com.bsd.pjcloud.hard.service.HardCarRecordService;
import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
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.springdoc.core.annotations.ParameterObject;
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;
import java.util.List;
/**
* 购物车商品记录管理
*
* <p>以购物车商品明细为查询维度的记录页接口,一行对应一条商品快照及其所属订单摘要;
* 只提供分页查询和异步导出,购物车表维护仍由 {@link HardCarController} 负责。</p>
*
* @author dzc
* @date 2026-09-15
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/hard-car-record")
@Tag(description = "hard-car-record", name = "购物车商品记录管理")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class HardCarRecordController {
private final HardCarRecordService hardCarRecordService;
private final HardCarRecordExportService hardCarRecordExportService;
private final HardManagerService hardManagerService;
private final RemoteMchService mchService;
/**
* 分页复杂查询购物车商品记录。
*
* @param page 分页参数
* @param dto 商品、订单、用户、部门、时间及身份筛选条件
* @return 已装配用户、商户展示资料的商品记录分页
*/
@Operation(summary = "分页复杂查询", description = "以购物车商品明细为维度分页查询记录")
@PostMapping("/page-join")
@PreAuthorize("@pms.hasPermission('hard_hardCarRecord_view')")
public R pageJoin(@ParameterObject Page page, @RequestBody HardCarRecordDto dto) {
DataScope dataScope = prepareCarRecordQuery(dto);
return R.ok(hardCarRecordService.scopePageList(page, dto, dataScope));
}
/**
* 提交购物车商品记录异步分批导出任务。
*
* @param dto 商品记录页面当前筛选条件;carIds 非空时仅导出勾选的商品明细行
* @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
*/
@Operation(summary = "购物车商品记录异步导出", description = "按商品记录筛选条件分批查询并流式生成 Excel")
@SysLog("购物车商品记录异步导出")
@PostMapping("/export-task")
@PreAuthorize("@pms.hasPermission('hard_hardCarRecord_export')")
public R<AsyncTaskProgressVO> submitExport(@RequestBody HardCarRecordDto dto) {
return R.ok(hardCarRecordExportService.submitExport(dto), "购物车记录导出任务已提交");
}
/**
* 为商品记录分页统一注入设备类型权限、单商户子级范围及商户部门数据权限。
*
* @param dto 商品记录筛选条件,方法会写入权限侧商户类型集合
* @return 商户部门数据权限配置
*/
private DataScope prepareCarRecordQuery(HardCarRecordDto dto) {
DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL);
dataScope.setScopeDeptName("merchant_dept_id");
PjcloudUser user = SecurityUtils.getUser();
dto.setMchTypeCodes(hardManagerService.getCurrentUserHardTypeInfo(user).getHardTypeList());
// 查询商户和下面的子商户
List<Long> mchDtoIds = dto.getMchIds();
if (ObjectUtil.isNotEmpty(mchDtoIds) && mchDtoIds.size() == 1) {
Long mchId = mchDtoIds.get(0);
List<MchManageVo> mchList = mchService.getByParentId(mchId).getData();
if (ObjectUtil.isNotEmpty(mchList)) {
List<Long> mchIds = mchList.stream().map(MchManageVo::getId).toList();
mchDtoIds.addAll(mchIds);
dto.setMchIds(mchDtoIds);
}
}
return dataScope;
}
}
@@ -0,0 +1,33 @@
package com.bsd.pjcloud.hard.mapper.order;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.hard.api.dto.order.HardCarRecordDto;
import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardCarRecordVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 购物车商品记录查询 Mapper,以购物车商品明细为维度关联订单展示信息。
*
* @author dzc
* @date 2026-09-15
*/
@Mapper
public interface HardCarRecordMapper extends PjcloudBaseMapper<HardCarEntity> {
/**
* 按商品记录筛选条件和数据范围分页查询购物车商品明细及其所属订单摘要。
*
* <p>查询列保留 merchant_dept_id 供数据权限拦截器包装部门过滤;排序以订单创建时间倒序、
* 订单主键倒序加购物车行主键倒序保证跨页稳定,列表与异步导出共用同一口径。</p>
*
* @param page 分页参数
* @param dto 商品、订单、用户、部门、时间及身份转换后的筛选条件
* @param dataScope 后台商户部门数据范围
* @return 当前页商品记录及符合相同筛选条件的总数
*/
Page<HardCarRecordVo> recordPage(Page page, @Param("dto") HardCarRecordDto dto, DataScope dataScope);
}
@@ -0,0 +1,21 @@
package com.bsd.pjcloud.hard.service;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.hard.api.dto.order.HardCarRecordDto;
/**
* 购物车商品记录异步导出服务。
*
* @author dzc
* @date 2026-09-15
*/
public interface HardCarRecordExportService {
/**
* 提交购物车商品记录异步分批导出任务。
*
* @param dto 商品记录页面当前筛选条件;carIds 非空时仅导出勾选的商品明细行
* @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
*/
AsyncTaskProgressVO submitExport(HardCarRecordDto dto);
}
@@ -0,0 +1,28 @@
package com.bsd.pjcloud.hard.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.hard.api.dto.order.HardCarRecordDto;
import com.bsd.pjcloud.hard.api.vo.order.HardCarRecordVo;
/**
* 购物车商品记录查询服务,以购物车商品明细为维度装配订单和用户展示信息。
*
* @author dzc
* @date 2026-09-15
*/
public interface HardCarRecordService {
/**
* 按商品记录筛选条件和数据范围分页查询购物车商品明细。
*
* <p>身份筛选条件先转换为用户主键范围再进入 SQL;返回行包含所属订单摘要,并批量装配
* 用户资料、商户名称和钱包类型,列表页与导出任务共用同一查询口径。</p>
*
* @param page 分页参数
* @param dto 商品、订单、用户、部门、时间及身份筛选条件
* @param dataScope 后台商户部门数据范围
* @return 已完成展示信息装配的商品记录分页结果
*/
Page<HardCarRecordVo> scopePageList(Page page, HardCarRecordDto dto, DataScope dataScope);
}
@@ -0,0 +1,804 @@
package com.bsd.pjcloud.hard.service.impl.order;
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.feign.RemoteFileService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult;
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.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.HardCarRecordDto;
import com.bsd.pjcloud.hard.api.vo.order.HardCarRecordVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCarRecordExportExcelVo;
import com.bsd.pjcloud.hard.mapper.order.HardCarRecordMapper;
import com.bsd.pjcloud.hard.service.HardCarRecordExportService;
import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
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.beans.BeanUtils;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
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;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* 购物车商品记录异步导出服务实现。
*
* <p>导出前冻结当前用户可见的设备类型、商户范围和身份筛选,后台任务按稳定顺序分页读取商品明细,
* 并在同一任务线程同步补充完整用户资料和商户名称,避免列表服务公共线程池丢失 Feign 登录凭证。
* 使用 EasyExcel 自定义二级表头分片写入,完成后上传文件中心并返回下载地址。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class HardCarRecordExportServiceImpl implements HardCarRecordExportService {
/**
* 商品记录导出日志标签,用于定位分批查询、工作簿写入和文件上传异常。
*/
private static final String LOG_TAG = "购物车记录导出";
/**
* 商品记录导出任务的 Redis Key 业务命名空间。
*/
private static final String CAR_RECORD_EXPORT_REDIS_KEY = "hard:carRecord:export";
/**
* 商品记录导出任务在进度弹窗中的展示名称。
*/
private static final String CAR_RECORD_EXPORT_TASK_NAME = "购物车记录导出";
/**
* 后台取得真实记录数前创建任务使用的占位总数。
*/
private static final int CAR_RECORD_EXPORT_INITIAL_TOTAL = 1;
/**
* 单次商品记录查询和 EasyExcel 流式写入使用的记录数。
*/
private static final int CAR_RECORD_EXPORT_BATCH_SIZE = 500;
/**
* 文件上传及异步任务成功收尾占用的额外进度步数。
*/
private static final int CAR_RECORD_EXPORT_EXTRA_PROGRESS_STEPS = 2;
/**
* EasyExcel 二级表头占用的固定行数。
*/
private static final int CAR_RECORD_EXPORT_HEADER_ROW_COUNT = 2;
/**
* 全部商品明细之后写入的合计行数量。
*/
private static final int CAR_RECORD_EXPORT_SUMMARY_ROW_COUNT = 1;
/**
* 单个工作表最多允许写入的明细数量。
*/
private static final int MAX_CAR_RECORD_EXPORT_ROWS = BatchExcelExportTemplate.calculateMaxDetailRowCount(
CAR_RECORD_EXPORT_HEADER_ROW_COUNT, CAR_RECORD_EXPORT_SUMMARY_ROW_COUNT);
/**
* 商品记录导出工作表名称。
*/
private static final String CAR_RECORD_EXPORT_SHEET_NAME = "购物车记录";
/**
* 文件中心保存商品记录导出文件的业务目录。
*/
private static final String CAR_RECORD_EXPORT_FILE_DIR = "hard-export";
/**
* 文件中心区分商品记录导出文件的业务类型编码。
*/
private static final String CAR_RECORD_EXPORT_FILE_TYPE = "HARD_CAR_RECORD_EXPORT";
/**
* XLSX 文件的标准 MIME 类型。
*/
private static final String CAR_RECORD_EXPORT_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
/**
* 异步商品记录导出文件名使用的时间格式。
*/
private static final DateTimeFormatter CAR_RECORD_EXPORT_FILE_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
/**
* Excel 日期时间字段统一展示格式,避免 EasyExcel 依赖服务端默认格式。
*/
private static final DateTimeFormatter EXCEL_DATE_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* Excel 预约用餐日期字段统一展示格式。
*/
private static final DateTimeFormatter EXCEL_DATE_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd");
/**
* 购物车商品记录 Mapper,负责在当前导出任务线程内按数据权限分页读取原始记录。
*/
private final HardCarRecordMapper hardCarRecordMapper;
/**
* 用户身份支持器,负责把身份筛选条件预转换为用户主键范围并同步装配展示资料。
*/
private final HardUserIdentitySupport userIdentitySupport;
/**
* 设备服务,用于冻结当前用户允许查看的设备类型。
*/
private final HardManagerService hardManagerService;
/**
* 商户远程服务,用于把单个商户筛选扩展为其现有子商户范围并查询商户名称。
*/
private final RemoteMchService remoteMchService;
/**
* 文件中心远程服务,用于持久化异步生成的商品记录工作簿。
*/
private final RemoteFileService remoteFileService;
/**
* 通用异步任务执行器,负责恢复租户、安全和 Feign Token 上下文并持久化进度。
*/
private final AsyncTaskExecutorService asyncTaskExecutorService;
/**
* 提交购物车商品记录异步分批导出任务。
*
* @param dto 商品记录页面当前筛选条件;carIds 非空时仅导出勾选的商品明细行
* @return 可供前端轮询的初始任务进度
*/
@Override
public AsyncTaskProgressVO submitExport(HardCarRecordDto dto) {
HardCarRecordDto querySnapshot = prepareExportQuery(copyQuery(dto));
return asyncTaskExecutorService.submitWithResult(CAR_RECORD_EXPORT_REDIS_KEY, CAR_RECORD_EXPORT_TASK_NAME,
CAR_RECORD_EXPORT_INITIAL_TOTAL, reporter -> exportToFile(querySnapshot, reporter));
}
/**
* 在异步线程中生成商品记录工作簿并上传文件中心。
*
* @param query 已冻结权限和筛选条件的商品记录查询
* @param reporter 当前异步任务进度报告器
* @return 文件中心下载地址和业务文件名
*/
private AsyncTaskResult exportToFile(HardCarRecordDto query, AsyncTaskProgressReporter reporter) {
Page<HardCarRecordVo> firstPage = fetchExportPage(1L, CAR_RECORD_EXPORT_BATCH_SIZE, query, true);
long total = validateExportTotal(firstPage.getTotal());
int progressTotal = BatchExcelExportTemplate.calculateProgressTotal(total, CAR_RECORD_EXPORT_BATCH_SIZE,
CAR_RECORD_EXPORT_EXTRA_PROGRESS_STEPS);
reporter.updateTotal(progressTotal, "已查询 " + total + " 条商品记录,将分批生成文件");
String fileName = "购物车记录_" + LocalDateTime.now().format(CAR_RECORD_EXPORT_FILE_TIME_FORMATTER) + ".xlsx";
try {
String fileUrl = writeExportWorkbook(query, firstPage, total, reporter,
file -> uploadExportFile(file, fileName));
reporter.advance("购物车记录导出文件已上传,共 " + total + " 条");
return AsyncTaskResult.file(fileUrl, fileName);
} catch (IllegalArgumentException | IllegalStateException exception) {
throw exception;
} catch (Exception exception) {
log.error("|{}|生成或上传文件失败,fileName:{}", LOG_TAG, fileName, exception);
throw new IllegalStateException("生成购物车记录导出文件失败", exception);
}
}
/**
* 按页查询商品记录并使用 EasyExcel 流式写入自定义二级表头工作簿。
*
* <p>文件先写入临时路径,只有工作簿完整关闭后才交给上传回调;每批写入后上报一次进度,
* 避免大批量记录长期占用请求线程和堆内存。</p>
*
* @param query 已冻结的商品记录查询条件
* @param firstPage 已用于取得总数的第一页,避免重复查询首批记录
* @param total 导出开始时按相同条件统计的记录总数
* @param reporter 异步任务进度报告器
* @param fileConsumer 完整临时文件消费回调
* @param <R> 文件消费结果类型
* @return 文件消费回调返回的结果
*/
private <R> R writeExportWorkbook(HardCarRecordDto query, Page<HardCarRecordVo> firstPage, long total,
AsyncTaskProgressReporter reporter,
BatchExcelExportTemplate.FileConsumer<R> fileConsumer) {
Path outputPath = null;
try {
outputPath = Files.createTempFile("hard-car-record-export-", ".xlsx");
CarRecordExportSummary summary = new CarRecordExportSummary();
long processedTotal = 0L;
long current = 1L;
try (ExcelWriter excelWriter = EasyExcel.write(outputPath.toFile(), HardCarRecordExportExcelVo.class).build()) {
WriteSheet writeSheet = EasyExcel.writerSheet(CAR_RECORD_EXPORT_SHEET_NAME).build();
Page<HardCarRecordVo> currentPage = firstPage;
while (processedTotal < total) {
List<HardCarRecordVo> records = safeRecords(currentPage);
if (records.isEmpty()) {
break;
}
long remaining = total - processedTotal;
int recordCount = Math.toIntExact(Math.min(records.size(), remaining));
List<HardCarRecordVo> batch = records.subList(0, recordCount);
List<HardCarRecordExportExcelVo> excelRows = new ArrayList<>(batch.size());
for (HardCarRecordVo 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, CAR_RECORD_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 searchCount 是否执行总数查询,仅第一页需要
* @return 在当前任务线程同步装配完整用户资料和商户名称的导出分页
*/
private Page<HardCarRecordVo> fetchExportPage(long current, long size, HardCarRecordDto query, boolean searchCount) {
Page<HardCarRecordVo> page = new Page<>(current, size, searchCount);
Page<HardCarRecordVo> recordPage = hardCarRecordMapper.recordPage(page, query, createRecordDataScope());
disposeRows(recordPage.getRecords());
return recordPage;
}
/**
* 在导出任务线程中同步装配一个商品记录分片的用户资料和商户名称。
*
* @param records 当前商品记录分片,最多为商品记录导出批大小
*/
private void disposeRows(List<HardCarRecordVo> records) {
if (records == null || records.isEmpty()) {
return;
}
userIdentitySupport.fillUserInfo(records, true);
List<Long> merchantIds = records.stream().map(HardCarRecordVo::getMerchantId)
.filter(Objects::nonNull).distinct().toList();
Map<Long, String> merchantNameMap = fetchMerchantNameMap(merchantIds);
for (HardCarRecordVo item : records) {
if (StrUtil.isBlank(item.getMerchantName())) {
item.setMerchantName(merchantNameMap.get(item.getMerchantId()));
}
}
}
/**
* 批量查询商品记录商户名称并校验钱包服务业务响应。
*
* @param merchantIds 当前记录分片去重后的商户主键
* @return 商户主键到名称的映射;记录未关联系统商户时返回空映射
*/
private Map<Long, String> fetchMerchantNameMap(List<Long> merchantIds) {
if (merchantIds.isEmpty()) {
return Map.of();
}
R<List<MchManageVo>> response = remoteMchService.getByIds(merchantIds);
if (response == null || !response.isOk()) {
String message = response == null ? null : response.getMsg();
throw new IllegalStateException(StrUtil.blankToDefault(message, "查询商品记录导出商户资料失败"));
}
if (response.getData() == null || response.getData().isEmpty()) {
return Map.of();
}
return response.getData().stream().filter(merchant -> merchant != null && merchant.getId() != null
&& StrUtil.isNotBlank(merchant.getName()))
.collect(Collectors.toMap(MchManageVo::getId, MchManageVo::getName, (left, right) -> left,
LinkedHashMap::new));
}
/**
* 将一条商品记录转换为二级表头导出对象。
*
* @param item 当前商品记录,已完成用户资料和商户名称装配
* @param lineNumber 当前记录在全部导出结果中的一基序号
* @return 已完成枚举翻译、金额和时间格式化的精简导出行
*/
private HardCarRecordExportExcelVo toExcelRow(HardCarRecordVo item, long lineNumber) {
HardCarRecordExportExcelVo row = new HardCarRecordExportExcelVo();
row.setLineNumber(Long.toString(lineNumber));
row.setProductName(item.getProductName());
row.setProductPrice(formatPrice(item.getProductPrice()));
row.setProductCount(item.getProductCount());
row.setTotalPrice(formatPrice(item.getTotalPrice()));
row.setUserName(StrUtil.blankToDefault(item.getUsername(), ""));
row.setPhone(item.getUserMobile());
row.setDeptName(item.getUserDeptName());
row.setIdentityTypeNames(joinIdentityNames(item.getIdentityTypes()));
row.setStudentNo(item.getStudentInfo() == null ? null : item.getStudentInfo().getStudentNo());
row.setSchoolName(item.getStudentInfo() == null ? null : item.getStudentInfo().getSchoolName());
row.setClassName(item.getStudentInfo() == null ? null : item.getStudentInfo().getClassName());
row.setTeacherNo(item.getTeacherInfo() == null ? null : item.getTeacherInfo().getTeacherNo());
row.setOrderNo(item.getOrderNo());
row.setOrderType(resolveOrderType(item.getOrderType()));
row.setOrderStatus(resolveOrderStatus(item.getOrderStatus()));
row.setMerchantName(item.getMerchantName());
row.setHardName(item.getHardName());
row.setHardType(resolveHardType(item.getHardType()));
row.setPayType(resolvePayType(item.getPayType()));
row.setHourType(resolveHourType(item.getHourType()));
row.setCreateTime(formatDateTime(item.getOrderCreateTime()));
row.setPayTime(formatDateTime(item.getPayTime()));
row.setApptUseDate(item.getApptUseDate() == null ? null : item.getApptUseDate().format(EXCEL_DATE_FORMATTER));
row.setVerificationTime(formatDateTime(item.getVerificationTime()));
row.setRefundStatus(resolveRefundStatus(item.getRefundStatus()));
row.setRefundPrice(formatPrice(item.getRefundPrice()));
row.setRefundTime(formatDateTime(item.getRefundTime()));
return row;
}
/**
* 生成与明细相同结构的数量与金额合计行,确保 EasyExcel 保持二级表头列顺序。
*
* @param summary 全部分片累计的购买数量和小计金额
* @return 首列显示"合计"且数量、小计列写入汇总值的导出行
*/
private HardCarRecordExportExcelVo toSummaryExcelRow(CarRecordExportSummary summary) {
HardCarRecordExportExcelVo row = new HardCarRecordExportExcelVo();
row.setLineNumber("合计");
row.setProductCount(Math.toIntExact(summary.productCountSum));
row.setTotalPrice(summary.formatPrice(summary.totalPriceSum));
return row;
}
/**
* 将用户身份协议编码转换并拼接为中文名称,未知历史编码保留原值。
*
* @param identityTypes UPMS 装配得到的用户身份协议编码
* @return 以中文逗号分隔的身份名称;没有身份时返回空文本
*/
private String joinIdentityNames(List<String> identityTypes) {
if (identityTypes == null || identityTypes.isEmpty()) {
return "";
}
return identityTypes.stream().map(identityType -> {
UserIdentityTypeEnum identity = UserIdentityTypeEnum.valueOfByType(identityType);
return identity == null ? identityType : identity.getDescription();
}).filter(StrUtil::isNotBlank).collect(Collectors.joining(","));
}
/**
* 将订单类型协议编码转换为中文描述,未知历史编码原样保留。
*
* @param orderType 数据库和跨端接口使用的订单类型编码
* @return 订单类型中文描述、原始未知编码或空值
*/
private String resolveOrderType(String orderType) {
if (StrUtil.isBlank(orderType)) {
return orderType;
}
HardOrderTypeEnum value = HardOrderTypeEnum.valueOfByType(orderType);
return value == null ? orderType : value.getDescription();
}
/**
* 将订单状态协议编码安全转换为中文描述。
*
* <p>既有枚举解析方法对未知编码会抛异常,因此导出侧遍历枚举匹配,确保历史脏数据仍可导出。</p>
*
* @param orderStatus 数据库和跨端接口使用的订单状态编码
* @return 订单状态中文描述、原始未知编码或空值
*/
private String resolveOrderStatus(String orderStatus) {
if (StrUtil.isBlank(orderStatus)) {
return orderStatus;
}
for (HardOrderStatusEnum value : HardOrderStatusEnum.values()) {
if (Objects.equals(value.getType(), orderStatus)) {
return value.getDescription();
}
}
return orderStatus;
}
/**
* 将设备类型协议编码安全转换为中文描述,未知历史编码原样保留。
*
* @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;
}
/**
* 将支付方式协议编码转换为现有枚举中文描述,未知编码原样保留以兼容历史数据。
*
* @param payType 订单支付方式协议编码
* @return 支付方式中文描述或原始编码
*/
private String resolvePayType(String payType) {
if (StrUtil.isBlank(payType)) {
return payType;
}
HardPayTypeEnum value = HardPayTypeEnum.valueOfByType(payType);
return value == null ? payType : value.getDescription();
}
/**
* 将餐段协议编码转换为现有枚举中文描述,未知历史编码原样保留。
*
* @param hourType 用餐时间段协议编码
* @return 餐段中文描述、原始未知编码或空值
*/
private String resolveHourType(String hourType) {
if (StrUtil.isBlank(hourType)) {
return hourType;
}
HardHourTypeEnum value = HardHourTypeEnum.valueOfByType(hourType);
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;
}
/**
* 将金额从分转换为保留两位小数的元金额。
*
* @param amount 金额,单位为分;空值表示模板单元格留空
* @return 单位为元的金额;输入为空时返回空
*/
private BigDecimal formatPrice(Number amount) {
return amount == null ? null : BigDecimal.valueOf(amount.longValue())
.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 : dateTime.format(EXCEL_DATE_TIME_FORMATTER);
}
/**
* 上传完整商品记录工作簿并校验文件中心业务响应。
*
* @param file 已关闭并可读取的完整 XLSX 临时文件
* @param fileName 用户下载时使用的文件名
* @return 文件中心提供的下载地址
* @throws Exception 文件读取或远程上传失败时抛出
*/
private String uploadExportFile(Path file, String fileName) throws Exception {
MultipartFile exportFile = new MockMultipartFile("file", encodeUploadOriginalFilename(fileName),
CAR_RECORD_EXPORT_CONTENT_TYPE, Files.readAllBytes(file));
R<?> uploadResult = remoteFileService.innerUpload(exportFile, CAR_RECORD_EXPORT_FILE_DIR, null,
CAR_RECORD_EXPORT_FILE_TYPE);
if (uploadResult == null || !uploadResult.isOk()
|| !(uploadResult.getData() instanceof Map<?, ?> resultData)) {
throw new IllegalStateException("文件中心未返回有效的商品记录导出结果");
}
Object resultUrl = resultData.get("url");
if (resultUrl == null || StrUtil.isBlank(resultUrl.toString())) {
throw new IllegalStateException("文件中心未返回商品记录导出下载地址");
}
return resultUrl.toString();
}
/**
* 按文件服务现有约定转换中文原始文件名编码。
*
* @param fileName 用户看到的 UTF-8 文件名
* @return 文件中心上传接口使用的原始文件名
*/
private String encodeUploadOriginalFilename(String fileName) {
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);
}
}
/**
* 冻结当前用户允许查看的设备类型、身份用户范围和单商户的子商户范围。
*
* @param query 已复制、由当前任务独占的商品记录查询
* @return 已补充设备类型、身份用户和商户权限范围的同一查询对象
*/
private HardCarRecordDto prepareExportQuery(HardCarRecordDto query) {
CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(SecurityUtils.getUser());
query.setMchTypeCodes(hardTypeInfo == null || hardTypeInfo.getHardTypeList() == null
? List.of() : new ArrayList<>(hardTypeInfo.getHardTypeList()));
applyIdentityQuery(query);
List<Long> mchIds = query.getMchIds();
if (mchIds != null && mchIds.size() == 1) {
Long parentMchId = mchIds.get(0);
R<List<MchManageVo>> response = remoteMchService.getByParentId(parentMchId);
if (response == null || !response.isOk()) {
throw new IllegalStateException("查询导出子商户范围失败");
}
List<Long> expandedMchIds = new ArrayList<>();
expandedMchIds.add(parentMchId);
if (response.getData() != null) {
response.getData().stream().map(MchManageVo::getId).filter(Objects::nonNull)
.forEach(expandedMchIds::add);
}
query.setMchIds(expandedMchIds.stream().distinct().toList());
}
return query;
}
/**
* 将身份筛选一次性转换为用户主键范围,后续所有记录分片复用同一条件。
*
* @param query 当前导出任务独占的商品记录筛选条件,方法会清除已转换的身份字段
*/
private void applyIdentityQuery(HardCarRecordDto query) {
if (!userIdentitySupport.hasIdentityQuery(query)) {
return;
}
query.setUserIds(userIdentitySupport.findIdentityUserIds(query, query.getUserId(), query.getUserIds()));
query.setUserId(null);
query.setIdentityTypes(null);
query.setStudentInfo(null);
query.setTeacherInfo(null);
query.setHasFace(null);
}
/**
* 深复制商品记录导出筛选条件,避免请求对象中的集合或数组在后台任务开始前发生变化。
*
* @param source Controller 接收的商品记录筛选条件,允许为空
* @return 后台导出任务独占的筛选条件副本
*/
private HardCarRecordDto copyQuery(HardCarRecordDto source) {
HardCarRecordDto target = new HardCarRecordDto();
if (source == null) {
return target;
}
BeanUtils.copyProperties(source, target);
target.setCarIds(copyList(source.getCarIds()));
target.setOrderStatusCodes(copyList(source.getOrderStatusCodes()));
target.setOrderPayTypeCodes(copyList(source.getOrderPayTypeCodes()));
target.setHourTypeCodes(copyList(source.getHourTypeCodes()));
target.setMerchantTypeCodes(copyList(source.getMerchantTypeCodes()));
target.setMchTypeCodes(copyList(source.getMchTypeCodes()));
target.setHardTypeCodes(copyList(source.getHardTypeCodes()));
target.setMchIds(copyList(source.getMchIds()));
target.setUserIds(copyList(source.getUserIds()));
target.setUserDeptIds(copyList(source.getUserDeptIds()));
target.setIdentityTypes(copyList(source.getIdentityTypes()));
target.setCreateTimeList(cloneArray(source.getCreateTimeList()));
target.setPayTimeList(cloneArray(source.getPayTimeList()));
target.setVerificationTimeList(cloneArray(source.getVerificationTimeList()));
target.setApptUseDateTimeList(cloneArray(source.getApptUseDateTimeList()));
target.setStudentInfo(copyStudentInfo(source.getStudentInfo()));
target.setTeacherInfo(copyTeacherInfo(source.getTeacherInfo()));
return target;
}
/**
* 复制可变列表,保留空值语义并隔离调用方后续修改。
*
* @param source 原始列表,允许为空
* @param <T> 列表元素类型
* @return 独立列表副本;输入为空时返回空
*/
private <T> List<T> copyList(List<T> source) {
return source == null ? null : new ArrayList<>(source);
}
/**
* 复制日期时间范围数组,保留空值语义。
*
* @param source 原始日期时间范围,允许为空
* @return 独立数组副本;输入为空时返回空
*/
private LocalDateTime[] cloneArray(LocalDateTime[] source) {
return source == null ? null : source.clone();
}
/**
* 复制学生身份扩展筛选条件,避免异步任务共享请求对象。
*
* @param source 学生身份筛选条件,允许为空
* @return 独立学生筛选副本;输入为空时返回空
*/
private com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto copyStudentInfo(
com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto source) {
if (source == null) {
return null;
}
com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto target =
new com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto();
BeanUtils.copyProperties(source, target);
return target;
}
/**
* 复制老师身份扩展筛选条件,避免异步任务共享请求对象。
*
* @param source 老师身份筛选条件,允许为空
* @return 独立老师筛选副本;输入为空时返回空
*/
private com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto copyTeacherInfo(
com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto source) {
if (source == null) {
return null;
}
com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto target =
new com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto();
BeanUtils.copyProperties(source, target);
return target;
}
/**
* 创建商品记录页面使用的数据权限配置,以商户部门字段限制当前用户可见记录。
*
* @return 独立的数据权限参数,供单次分页查询使用
*/
private DataScope createRecordDataScope() {
DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL);
dataScope.setScopeDeptName("merchant_dept_id");
return dataScope;
}
/**
* 校验导出总数和单工作表容量。
*
* @param total 第一页分页查询返回的商品记录总数
* @return 已通过校验的商品记录总数
*/
private long validateExportTotal(long total) {
if (total <= 0L) {
throw new IllegalStateException("没有符合当前条件的购物车商品记录");
}
if (total > MAX_CAR_RECORD_EXPORT_ROWS) {
throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 " + MAX_CAR_RECORD_EXPORT_ROWS
+ " 条,请缩小查询范围后分批导出");
}
return total;
}
/**
* 获取分页记录并把空记录统一为不可变空列表。
*
* @param page 商品记录分页结果
* @return 当前页导出行;分页或记录为空时返回空列表
*/
private List<HardCarRecordVo> safeRecords(Page<HardCarRecordVo> page) {
return page == null || page.getRecords() == null ? List.of() : page.getRecords();
}
/**
* 商品记录导出跨分页汇总器,数量按整数累计、小计金额按数据库分单位累计。
*/
private static final class CarRecordExportSummary {
/**
* 购买数量累计值。
*/
private long productCountSum = 0L;
/**
* 商品小计金额累计值,单位为分。
*/
private BigDecimal totalPriceSum = BigDecimal.ZERO;
/**
* 累计当前商品记录的购买数量和小计金额,历史空值按零处理。
*
* @param item 当前商品记录
*/
private void add(HardCarRecordVo item) {
if (item.getProductCount() != null) {
productCountSum += item.getProductCount();
}
if (item.getTotalPrice() != null) {
totalPriceSum = totalPriceSum.add(BigDecimal.valueOf(item.getTotalPrice()));
}
}
/**
* 将累计的分金额转换为两位小数元金额。
*
* @param amount 累计金额,单位为分
* @return 单位为元的两位小数金额
*/
private BigDecimal formatPrice(BigDecimal amount) {
return amount.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
}
}
}
@@ -0,0 +1,136 @@
package com.bsd.pjcloud.hard.service.impl.order;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.CollectionUtils;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.hard.api.dto.order.HardCarRecordDto;
import com.bsd.pjcloud.hard.api.vo.order.HardCarRecordVo;
import com.bsd.pjcloud.hard.mapper.order.HardCarRecordMapper;
import com.bsd.pjcloud.hard.service.HardCarRecordService;
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService;
import com.bsd.pjcloud.wallet.api.utils.WalletDataUtil;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.WalletManageVo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
/**
* 购物车商品记录查询服务实现。
*
* <p>身份筛选统一转换为用户主键范围后进入 SQL;当前页记录在查询返回后并行装配用户资料、
* 商户名称和钱包类型,装配口径与订单列表保持一致。列表页和异步导出任务共用本实现,
* 保证两个入口的数据口径完全相同。</p>
*/
@RequiredArgsConstructor
@Service
public class HardCarRecordServiceImpl implements HardCarRecordService {
/**
* 购物车商品记录 Mapper,负责按数据范围分页读取商品明细及订单摘要。
*/
private final HardCarRecordMapper hardCarRecordMapper;
/**
* 用户身份支持器,负责把身份筛选条件预转换为用户主键范围并装配展示资料。
*/
private final HardUserIdentitySupport userIdentitySupport;
/**
* 商户远程服务,用于批量查询商户名称和 Logo。
*/
private final RemoteMchService remoteMchService;
/**
* 钱包远程服务,用于批量查询钱包类型编码。
*/
private final RemoteWalletService remoteWalletService;
/**
* 按筛选条件和数据范围分页查询商品记录,并在身份条件转换后装配跨服务展示资料。
*
* @param page 分页参数
* @param dto 商品、订单、用户、部门、时间及身份筛选条件
* @param dataScope 后台商户部门数据范围
* @return 已完成展示信息装配的商品记录分页结果
*/
@Override
public Page<HardCarRecordVo> scopePageList(Page page, HardCarRecordDto dto, DataScope dataScope) {
applyIdentityQuery(dto);
Page<HardCarRecordVo> list = hardCarRecordMapper.recordPage(page, dto, dataScope);
disposeList(list.getRecords());
return list;
}
/**
* 把身份筛选一次性转换为用户主键范围,无匹配用户时写入无效主键避免空集合放宽查询。
*
* @param dto 当前商品记录筛选条件,方法会清除已转换的身份字段
*/
private void applyIdentityQuery(HardCarRecordDto dto) {
if (!userIdentitySupport.hasIdentityQuery(dto)) {
return;
}
dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds()));
dto.setUserId(null);
dto.setIdentityTypes(null);
dto.setStudentInfo(null);
dto.setTeacherInfo(null);
dto.setHasFace(null);
}
/**
* 批量装配商品记录的用户资料、商户名称和钱包类型,各独立数据源并行执行以缩短整体等待时间。
*
* @param list 当前页商品记录;空列表不发起任何关联查询
*/
private void disposeList(List<HardCarRecordVo> list) {
if (ObjectUtil.isEmpty(list)) {
return;
}
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
// 用户与身份资料
CompletableFuture<Void> userFuture = CompletableFuture.runAsync(() -> {
RequestContextHolder.setRequestAttributes(requestAttributes);
userIdentitySupport.fillUserInfo(list, true);
});
// 商户名称与 Logo
CompletableFuture<Void> mchFuture = CompletableFuture.runAsync(() -> {
RequestContextHolder.setRequestAttributes(requestAttributes);
List<Long> mchIds = list.stream().map(HardCarRecordVo::getMerchantId)
.filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(mchIds)) {
List<MchManageVo> mchList = remoteMchService.getByIds(mchIds).getData();
if (ObjectUtil.isNotEmpty(mchList)) {
WalletDataUtil.convertMchList(list, CollectionUtils.convertMap(mchList, MchManageVo::getId));
}
}
});
// 钱包类型
CompletableFuture<Void> walletFuture = CompletableFuture.runAsync(() -> {
RequestContextHolder.setRequestAttributes(requestAttributes);
List<Long> walletIds = list.stream().map(HardCarRecordVo::getWalletId)
.filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(walletIds)) {
List<WalletManageVo> walletList = remoteWalletService.getWalletByIds(walletIds).getData();
Map<Long, WalletManageVo> walletMap = CollectionUtils.convertMap(walletList, WalletManageVo::getId);
for (HardCarRecordVo item : list) {
WalletManageVo wallet = walletMap.get(item.getWalletId());
if (ObjectUtil.isEmpty(wallet)) {
continue;
}
item.setWalletTypeUniqueCode(wallet.getWalletTypeUniqueCode());
}
}
});
CompletableFuture.allOf(userFuture, mchFuture, walletFuture).join();
}
}
@@ -0,0 +1,165 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bsd.pjcloud.hard.mapper.order.HardCarRecordMapper">
<!--
购物车商品记录分页查询:一行对应一条 hard_car 商品快照,关联父购物车和订单取购买人与订单摘要。
join 口径与 HardCarMapper 统计查询一致(o.parent_car_id = p.id);merchant_dept_id 必须保留在
查询列中,数据权限拦截器对部门权限用户包一层 SELECT * FROM (本查询) temp_data_scope
WHERE temp_data_scope.merchant_dept_id IN (...),缺少该列会直接 SQL 报错。
排序含 a.id 唯一键,保证异步导出跨页读取不重不漏。
-->
<select id="recordPage" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCarRecordVo">
select a.id,
a.parent_car_id,
a.product_id,
a.source_product_id,
a.product_name,
a.product_pic,
a.product_price,
a.product_nature,
a.product_count,
a.product_weight,
a.total_price,
a.create_time as car_create_time,
o.id as order_id,
o.order_no,
o.user_id,
o.order_type,
o.order_status,
o.pay_type,
o.hour_type,
o.appt_use_date,
o.verification_time,
o.verification_type,
o.create_time as order_create_time,
o.pay_time,
o.total_price as order_total_price,
o.extra_price,
o.delivery_price,
o.pay_price,
o.refund_price,
o.refund_status,
o.refund_time,
o.wallet_id,
o.sys_type,
o.is_offline_order,
o.merchant_id,
o.merchant_type,
o.merchant_dept_id,
o.hard_id,
p.user_id as payer_user_id,
m.name as hard_name,
m.type as hard_type
from hard_car a
inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0'
left join hard_manager m on m.id = o.hard_id
<where>
a.del_flag = '0'
<if test="dto.carIds != null and dto.carIds.size() != 0">
and a.id in
<foreach collection="dto.carIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.productId != null">
and a.product_id = #{dto.productId}
</if>
<if test="dto.productName != null and dto.productName != ''">
and a.product_name like concat('%', #{dto.productName}, '%')
</if>
<if test="dto.orderNo != null and dto.orderNo != ''">
and o.order_no = #{dto.orderNo}
</if>
<if test="dto.orderType != null and dto.orderType != ''">
and o.order_type = #{dto.orderType}
</if>
<if test="dto.orderStatusCodes != null and dto.orderStatusCodes.size() != 0">
and o.order_status in
<foreach collection="dto.orderStatusCodes" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.orderPayTypeCodes != null and dto.orderPayTypeCodes.size() != 0">
and o.pay_type in
<foreach collection="dto.orderPayTypeCodes" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.hourTypeCodes != null and dto.hourTypeCodes.size() != 0">
and o.hour_type in
<foreach collection="dto.hourTypeCodes" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<!-- 页面商户类型多选与服务端注入的设备类型权限条件分别保留,最终按交集筛选。 -->
<if test="dto.merchantTypeCodes != null and dto.merchantTypeCodes.size() != 0">
and o.merchant_type in
<foreach collection="dto.merchantTypeCodes" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.mchTypeCodes != null and dto.mchTypeCodes.size() != 0">
and o.merchant_type in
<foreach collection="dto.mchTypeCodes" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.hardTypeCodes != null and dto.hardTypeCodes.size() != 0">
and m.type in
<foreach collection="dto.hardTypeCodes" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.merchantId != null">
and o.merchant_id = #{dto.merchantId}
</if>
<if test="dto.mchIds != null and dto.mchIds.size() != 0">
and o.merchant_id in
<foreach collection="dto.mchIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.userId != null">
and o.user_id = #{dto.userId}
</if>
<if test="dto.userIds != null and dto.userIds.size() != 0">
and o.user_id in
<foreach collection="dto.userIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.userDeptId != null">
and o.user_dept_id = #{dto.userDeptId}
</if>
<if test="dto.userDeptIds != null and dto.userDeptIds.size() != 0">
<choose>
<when test="dto.isInclude != null and dto.isInclude == false">
and o.user_dept_id not in
</when>
<otherwise>
and o.user_dept_id in
</otherwise>
</choose>
<foreach collection="dto.userDeptIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.createTimeList != null and dto.createTimeList.length == 2">
and o.create_time between #{dto.createTimeList[0]} and #{dto.createTimeList[1]}
</if>
<if test="dto.payTimeList != null and dto.payTimeList.length == 2">
and o.pay_time between #{dto.payTimeList[0]} and #{dto.payTimeList[1]}
</if>
<if test="dto.verificationTimeList != null and dto.verificationTimeList.length == 2">
and o.verification_time between #{dto.verificationTimeList[0]} and #{dto.verificationTimeList[1]}
</if>
<if test="dto.apptUseDateTimeList != null and dto.apptUseDateTimeList.length == 2">
and o.appt_use_date between #{dto.apptUseDateTimeList[0]} and #{dto.apptUseDateTimeList[1]}
</if>
</where>
order by o.create_time desc, o.id desc, a.id desc
</select>
</mapper>