Merge remote-tracking branch 'origin/master'

This commit is contained in:
zjf committed 2026-09-17 14:34:44 +08:00
commit fff9b6e40f
44 files changed
+3756 -242

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
+1
View File
@@ -93,6 +93,7 @@
## 开发规则
- **新增源码文件不写版权/许可注释头**(`Copyright (c) pjcloud` 开头的大段 `/* */` 块)。既有文件里的此类注释头保持原样,不主动清理、不扩散到新文件。
- 修改前先确认所属业务模块及其 `api` / `biz` 边界,沿用现有 Controller、Service、Mapper、DTO、VO、Feign Client、异常处理、鉴权和统一响应结构。
- 跨服务调用优先遵循项目现有 OpenFeign 封装、服务名、请求模型、降级与异常处理方式,不得绕过已有边界直接耦合其他服务内部实现。
- Redis 使用必须复用已有缓存工具、序列化方式、键命名规则和过期策略。新增或修改缓存逻辑时,必须考虑缓存穿透、数据一致性、失效时机和空值处理。
@@ -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,55 @@
-- 食堂预约人员统计(/hard/hard-canteen-appt-total-log/app/user-reserv-daily-statistics 等)查询索引。
-- 幂等迁移脚本:可反复执行。索引先查 information_schema,已存在则跳过(执行 SELECT 1 占位),
-- 不存在才执行 DDL;已应用过的库重跑不会报 Duplicate key。
-- 背景:人员预约统计按商户范围 + 预约/核销日期范围查询 hard_order(基线 DDL 仅有主键索引,
-- 只能全表扫),退款净商品聚合按 parent_car_id 过滤 hard_after_sale_car 同样无索引。
-- 配套代码改动:HardCarMapper.listEffectiveReservationCars 已把父购物车 IN 条件下推进内层聚合,
-- hard_after_sale_car 需要本索引才能走索引范围扫描;hard_order 的两个组合索引分别覆盖
-- 默认(appt_use_date 范围)与已核销(verification_time 范围)两种统计口径。
-- 执行顺序:本脚本须先于 pjcloud-hardware-biz 新版本部署手工执行。
-- 取舍说明:hard_order 由此新增 2 个二级索引(连同 20260915 的 parent_car 索引共 3 个二级索引),
-- 离线消费机批量补传等写入链路会有少量额外索引维护成本;若确认已核销口径流量占比很低,
-- 可先只执行前两个索引、暂缓 idx_hard_order_tenant_mch_verify。
-- 【可选诊断】相关表现有索引一览,执行前后可对比复核。
SELECT TABLE_NAME, 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_order', 'hard_car', 'hard_after_sale_car')
GROUP BY TABLE_NAME, INDEX_NAME;
-- hard_order 默认统计口径:tenant 等值 + 商户等值集合 + 预约使用日期范围。
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_order` ADD KEY `idx_hard_order_tenant_mch_appt` (`tenant_id`, `merchant_id`, `appt_use_date`)',
'SELECT 1')
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order'
AND INDEX_NAME = 'idx_hard_order_tenant_mch_appt');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- hard_order 已核销统计口径:核销时间范围替代预约使用日期范围。
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_order` ADD KEY `idx_hard_order_tenant_mch_verify` (`tenant_id`, `merchant_id`, `verification_time`)',
'SELECT 1')
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order'
AND INDEX_NAME = 'idx_hard_order_tenant_mch_verify');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- hard_after_sale_car 退款净额聚合:父购物车下推过滤后走索引范围扫描,避免全表聚合。
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_after_sale_car` ADD KEY `idx_hard_after_sale_car_parent_del` (`parent_car_id`, `del_flag`)',
'SELECT 1')
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_after_sale_car'
AND INDEX_NAME = 'idx_hard_after_sale_car_parent_del');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- hard_car 侧已有 idx_hard_car_parent_tenant_del(parent_car_id, tenant_id, del_flag),
-- 下推后的购物车聚合直接复用,无需新增;以上判断依据上方诊断查询输出,重跑时可复核。
@@ -164,14 +164,15 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
return R.failed(MsgUtils.getMessage(ErrorCodes.HARD_PRODUCT_STOCK_LOW,
StrUtil.blankToDefault(spu.getName(), "商品")));
}
if (applyOperation) {
// 不得把“库存恰好等于目标值”当成本业务已经执行;该值可能由另一个业务号并发扣减得到。
// 幂等成功只能依赖上方读取到的 APPLIED 状态,状态缺失时必须从调用方快照原子更新。
if (applyOperation && item.getIncrCount() < 0) {
// 负增量扣减必须以快照做前置校验:不得把“库存恰好等于目标值”当成本业务已经执行,
// 该值可能由另一个业务号并发扣减得到,且并发扣减基于同一剩余量判断会超卖。
// 正增量恢复是可交换操作,不做快照校验,见下方更新循环注释。
if (spu.getStock() != expectedStock) {
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_CHANGED,
StrUtil.blankToDefault(spu.getName(), "商品")));
}
} else if ("APPLIED".equals(state)) {
} else if (rollbackOperation && "APPLIED".equals(state)) {
int rollbackDelta = -item.getIncrCount();
if (rollbackDelta < 0 && spu.getStock() < Math.abs(rollbackDelta)) {
return R.failed(MsgUtils.getMessage(ErrorCodes.HARD_PRODUCT_STOCK_LOW,
@@ -192,8 +193,17 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
continue;
}
// 状态缺失的 ROLLBACK 说明没有可确认的成功 APPLY,只写入终态阻断延迟请求,不能按库存值猜测并恢复。
if (applyOperation && baseMapper.updateStockIfMatch(item.getId(), delta, expectedStock) == 0) {
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_CHANGED, "");
if (applyOperation) {
// 正增量恢复库存是可交换操作:并发下单或退款改变的库存不影响本业务恢复量的正确性,
// 直接原子累加即可,幂等仍由 bizId 分布式锁和 Redis 状态保证,
// 避免退款恢复被其他业务号的并发库存变更误判为冲突(“库存已变化”)。
// 负增量扣减保留快照 CAS 条件更新,防止不同业务号基于同一剩余量重复判断导致超卖。
int updated = delta > 0 ? baseMapper.updateStock(item.getId(), delta)
: baseMapper.updateStockIfMatch(item.getId(), delta, expectedStock);
if (updated == 0) {
throw new I18nBizException(delta > 0 ? ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL
: ErrorCodes.CANTEEN_STOCK_CHANGED, "");
}
}
}
String targetState = applyOperation ? "APPLIED"
@@ -277,7 +287,9 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
return R.failed(MsgUtils.getMessage(ErrorCodes.HARD_PRODUCT_STOCK_LOW,
StrUtil.blankToDefault(spu == null ? null : spu.getName(), "商品")));
}
if (applyOperation && current != expectedStock) {
// 正增量恢复是可交换操作,跳过剩余量快照校验,避免退款恢复被并发下单等无关变更误拒;
// 负增量扣减保留快照校验,防止不同业务号基于同一剩余量重复判断导致超卖。
if (applyOperation && item.getIncrCount() < 0 && current != expectedStock) {
CanteenSpuEntity spu = baseMapper.selectById(item.getId());
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_CHANGED,
StrUtil.blankToDefault(spu == null ? null : spu.getName(), "商品")));
@@ -306,13 +318,19 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
continue;
}
if (applyOperation) {
// 正增量恢复直接原子累加:expected 传 null 跳过剩余量匹配条件,SQL 也不带任何边界条件,
// 销量回退由 GREATEST 钳制到 0 为止——菜谱重导、按订单重算销量、配额为 0 等统计口径
// 都不得阻断退款/补偿;幂等由 bizId 锁和 Redis 状态保证。
// 负增量扣减保留快照匹配条件更新,防止不同业务号基于同一剩余量重复判断导致超卖。
Integer expected = delta > 0 ? null : item.getBeforeStock();
updated = offline
? canteenDailyMenuMapper.updateOfflineStockDelta(dto.getMerchantId(), item.getMealDate(),
item.getHourType(), item.getId(), delta, item.getBeforeStock())
item.getHourType(), item.getId(), delta, expected)
: canteenDailyMenuMapper.updateOnlineStockDelta(dto.getMerchantId(), item.getMealDate(),
item.getHourType(), item.getId(), delta, item.getBeforeStock());
item.getHourType(), item.getId(), delta, expected);
if (updated == 0) {
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_CHANGED, "");
throw new I18nBizException(delta > 0 ? ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL
: ErrorCodes.CANTEEN_STOCK_CHANGED, "");
}
}
}
@@ -324,17 +324,22 @@
delta 与旧接口一致:下单扣减传负数、售后回传正数。
实际改写销量:sold = sold - delta(扣减时 sold 增加)。
expected 为操作前「剩余」快照(配额 - 销量)。
正增量恢复不带任何边界条件:SET 已把销量钳制到 0 为止(菜谱重导或按订单重算销量后
sold 可能小于恢复量,退款/补偿不能被统计口径阻断),配额为 0 等脏数据同样不得阻断;
负增量扣减保留原「不扣成负销量、不超配额」边界,语义不变。
-->
<update id="updateOnlineStockDelta">
UPDATE canteen_daily_menu
SET online_sold = IFNULL(online_sold, 0) - #{delta}
SET online_sold = GREATEST(IFNULL(online_sold, 0) - #{delta}, 0)
WHERE del_flag = '0'
AND merchant_id = #{merchantId}
AND start_time = #{mealDate}
AND hour_type = #{hourType}
AND spu_id = #{spuId}
<if test="delta &lt;= 0">
AND IFNULL(online_sold, 0) - #{delta} &gt;= 0
AND IFNULL(online_sold, 0) - #{delta} &lt;= IFNULL(online_stock, 600)
</if>
<if test="expected != null">
AND (IFNULL(online_stock, 600) - IFNULL(online_sold, 0)) = #{expected}
</if>
@@ -342,14 +347,16 @@
<update id="updateOfflineStockDelta">
UPDATE canteen_daily_menu
SET offline_sold = IFNULL(offline_sold, 0) - #{delta}
SET offline_sold = GREATEST(IFNULL(offline_sold, 0) - #{delta}, 0)
WHERE del_flag = '0'
AND merchant_id = #{merchantId}
AND start_time = #{mealDate}
AND hour_type = #{hourType}
AND spu_id = #{spuId}
<if test="delta &lt;= 0">
AND IFNULL(offline_sold, 0) - #{delta} &gt;= 0
AND IFNULL(offline_sold, 0) - #{delta} &lt;= IFNULL(offline_stock, 600)
</if>
<if test="expected != null">
AND (IFNULL(offline_stock, 600) - IFNULL(offline_sold, 0)) = #{expected}
</if>
@@ -1,23 +1,37 @@
package com.bsd.pjcloud.canteen.service.impl;
import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.mapper.CanteenDailyMenuMapper;
import com.bsd.pjcloud.canteen.mapper.CanteenSpuMapper;
import com.bsd.pjcloud.canteen.service.CanteenSpuService;
import com.bsd.pjcloud.canteen.util.CanteenMerchantScopeHelper;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.core.util.SpringContextHolder;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.context.MessageSource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.Duration;
import java.time.LocalDate;
import java.util.List;
import java.util.Locale;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -32,6 +46,9 @@ class CanteenSpuServiceImplTest {
/** 基础 SPU Mapper,用于验证库存增量是否真实执行。 */
private CanteenSpuMapper spuMapper;
/** 每日菜谱 Mapper,用于验证分池正增量恢复的剩余匹配条件。 */
private CanteenDailyMenuMapper dailyMenuMapper;
/** Redis 字符串操作,用于模拟和验证库存业务状态迁移。 */
private ValueOperations<String, String> valueOperations;
@@ -41,11 +58,12 @@ class CanteenSpuServiceImplTest {
@BeforeEach
void setUp() {
spuMapper = mock(CanteenSpuMapper.class);
dailyMenuMapper = mock(CanteenDailyMenuMapper.class);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
valueOperations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
CanteenSpuServiceImpl target = new CanteenSpuServiceImpl(redisTemplate, null, null, null, null, null,
mock(CanteenMerchantScopeHelper.class), mock(CanteenDailyMenuStockSupport.class));
CanteenSpuServiceImpl target = new CanteenSpuServiceImpl(redisTemplate, dailyMenuMapper, null, null, null,
null, mock(CanteenMerchantScopeHelper.class), mock(CanteenDailyMenuStockSupport.class));
ReflectionTestUtils.setField(target, "baseMapper", spuMapper);
service = target;
TransactionSynchronizationManager.initSynchronization();
@@ -55,10 +73,13 @@ class CanteenSpuServiceImplTest {
@AfterEach
void tearDown() {
TransactionSynchronizationManager.clearSynchronization();
// 撤销最小 MessageSource 注入,避免静态 SpringContextHolder 影响后续测试类。
ReflectionTestUtils.setField(SpringContextHolder.class, "beanFactory", null);
}
/**
* 已确认应用后完成补偿的售后库存允许再次 APPLY;否则钱包成功后的任务重试会丢失库存恢复。
* 正增量恢复走无条件原子累加,不再与快照做 CAS 对比。
*/
@Test
void compensatedRefundInventoryShouldAllowRetry() {
@@ -66,15 +87,96 @@ class CanteenSpuServiceImplTest {
when(valueOperations.get("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9"))
.thenReturn("COMPENSATED");
when(spuMapper.selectById(1L)).thenReturn(spu(10));
when(spuMapper.updateStockIfMatch(1L, 2, 10)).thenReturn(1);
when(spuMapper.updateStock(1L, 2)).thenReturn(1);
assertTrue(service.updateSkuStockIdempotent(dto).isOk());
verify(spuMapper).updateStockIfMatch(1L, 2, 10);
verify(spuMapper).updateStock(1L, 2);
verify(valueOperations).set("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9", "APPLIED",
Duration.ofDays(7));
}
/**
* 正增量恢复是可交换操作:快照与当前库存不一致(并发下单/退款已改变库存)时仍应直接累加成功,
* 不得被误判为“库存已变化”阻断退款。
*/
@Test
void restoreApplyShouldIgnoreConcurrentStockChange() {
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", 2, 10);
when(spuMapper.selectById(1L)).thenReturn(spu(8));
when(spuMapper.updateStock(1L, 2)).thenReturn(1);
assertTrue(service.updateSkuStockIdempotent(dto).isOk());
verify(spuMapper).updateStock(1L, 2);
verify(valueOperations).set("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9", "APPLIED",
Duration.ofDays(7));
}
/**
* 负增量扣减仍要求当前库存等于快照,防止不同业务号基于同一剩余量重复判断导致超卖。
*/
@Test
void deductApplyShouldStillRejectChangedStock() {
registerMinimalMessageSource();
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", -2, 10);
when(spuMapper.selectById(1L)).thenReturn(spu(8));
R result = service.updateSkuStockIdempotent(dto);
assertFalse(result.isOk());
verify(spuMapper, never()).updateStock(anyLong(), anyInt());
verify(spuMapper, never()).updateStockIfMatch(anyLong(), anyInt(), anyInt());
}
/**
* 每日菜谱分池正增量恢复:当前剩余与快照不一致(菜谱重导/销量重算后)也不得阻断退款,
* 且传给 SQL 的剩余匹配条件为 null(销量回退由 SQL 钳制到 0 为止)。
*/
@Test
void dailyRestoreApplyShouldSkipRemainMatch() {
LocalDate mealDate = LocalDate.of(2026, 9, 14);
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
item.setId(100L);
item.setIncrCount(2);
item.setBeforeStock(5);
item.setMealDate(mealDate);
item.setHourType(1);
CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO();
dto.setBizId("HARD_DAILY_MENU_AFTER_SALE:9");
dto.setOperation("APPLY");
dto.setStockChannel("ONLINE");
dto.setMerchantId(1L);
dto.setItems(List.of(item));
// 配额 10、已售 0:剩余 10 与快照 5 不一致,正增量恢复仍应放行
when(dailyMenuMapper.selectOne(any())).thenReturn(dailyMenu(10, 0));
when(dailyMenuMapper.updateOnlineStockDelta(1L, mealDate, 1, 100L, 2, null)).thenReturn(1);
assertTrue(service.updateSkuStockIdempotent(dto).isOk());
verify(dailyMenuMapper).updateOnlineStockDelta(1L, mealDate, 1, 100L, 2, null);
verify(valueOperations).set("canteen:spu:order-stock:daily:ONLINE:HARD_DAILY_MENU_AFTER_SALE:9",
"APPLIED", Duration.ofDays(7));
}
/**
* 构造每日菜谱菜谱行实体。
*
* @param onlineStock 线上配额
* @param onlineSold 线上已售
* @return 仅包含本用例所需字段的实体
*/
private CanteenDailyMenuEntity dailyMenu(int onlineStock, int onlineSold) {
CanteenDailyMenuEntity menu = new CanteenDailyMenuEntity();
menu.setMerchantId(1L);
menu.setSpuId(100L);
menu.setHourType(1);
menu.setOnlineStock(onlineStock);
menu.setOnlineSold(onlineSold);
return menu;
}
/**
* 已执行 APPLY 的回滚必须标记为可重试补偿态,区别于阻断网络延迟 APPLY 的未确认回滚态。
*/
@@ -93,6 +195,19 @@ class CanteenSpuServiceImplTest {
Duration.ofDays(7));
}
/**
* 为纯 Mockito 环境注入最小 MessageSource,使 R.failed 分支的 MsgUtils 文案解析可用;
* 文案内容不影响断言,直接回显错误码。
*/
private void registerMinimalMessageSource() {
MessageSource messageSource = mock(MessageSource.class);
when(messageSource.getMessage(anyString(), any(), any(Locale.class)))
.thenAnswer(invocation -> invocation.getArgument(0, String.class));
ConfigurableListableBeanFactory factory = mock(ConfigurableListableBeanFactory.class);
when(factory.getBean("pjcloudMessageSource")).thenReturn(messageSource);
new SpringContextHolder().postProcessBeanFactory(factory);
}
/**
* 构造单个基础 SPU 的幂等库存请求。
*
@@ -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;
}
@@ -142,6 +142,9 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
/** App 当前访问者的有效被监护人。 */
private List<Long> appWardUserIds;
/** App 当前访问者的关系图谱用户(不含本人),其全部类型订单对访问者可见。 */
private List<Long> appRelatedUserIds;
/**
* `apptId`或者`appt_rule_id`不为null(筛选预约订单)
*/
@@ -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;
}
@@ -242,17 +242,20 @@ public class HardAfterSaleController {
}
/**
* 将误同意的人工钱包退款退回审核中,并同步冲正钱包入账和退款库存恢复。
* 将误同意的人工钱包退款退回审核中,并同步冲正钱包入账;默认重新占用本次退款恢复的商品库存,
* deductStock=false 时跳过库存重新占用,后续再次同意该售后也不会重复恢复库存。
*
* @param id 已完成人工审核的售后单 ID
* @param deductStock 是否重新占用本次退款恢复的商品库存,默认 true
* @return 资金、库存和售后状态均恢复成功时返回 {@code true}
*/
@PutMapping("/return-to-review")
@Operation(summary = "退回到审核前")
@Operation(summary = "退回到审核前", description = "是否扣减库存 deductStock 默认 true;false 时跳过库存重新占用,后续再次同意也不再恢复库存")
@PreAuthorize("@pms.hasPermission('hard_hardAfterSale_change')")
@Idempotent(expireTime = 10, key = "'/hard-after-sale/return-to-review' + #id", info = "正在处理中,请勿重复操作")
public R<Boolean> returnToReview(@RequestParam Long id) {
return R.ok(hardAfterSaleService.returnToReview(SecurityUtils.getUser().getId(), id));
public R<Boolean> returnToReview(@RequestParam Long id,
@RequestParam(value = "deductStock", defaultValue = "true") Boolean deductStock) {
return R.ok(hardAfterSaleService.returnToReview(SecurityUtils.getUser().getId(), id, deductStock));
}
@Operation(summary = "分页查询", description = "分页查询")
@@ -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;
}
}
@@ -687,6 +687,8 @@ public class HardOrderController {
private DataScope prepareAdminOrderQuery(HardOrderDto dto) {
DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL);
dataScope.setScopeDeptName("merchant_dept_id");
// 订单 create_by 不会是后台账号,个人权限子句匹配不到数据且 scopePageIds 未查询该列,关闭避免拼出 temp_data_scope.create_by 报错
dataScope.setScopeUserName(null);
PjcloudUser user = SecurityUtils.getUser();
CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user);
dto.setMchTypeCodes(hardTypeInfo.getHardTypeList());
@@ -710,6 +712,8 @@ public class HardOrderController {
public R<Page<HardOrderVo>> noRecogPageJoin(@ParameterObject Page page, @RequestBody HardOrderDto dto) {
DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL);
dataScope.setScopeDeptName("merchant_dept_id");
// 与 prepareAdminOrderQuery 同口径:scopePageIds 未查询 create_by,必须关闭个人权限子句
dataScope.setScopeUserName(null);
PjcloudUser user = SecurityUtils.getUser();
CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user);
dto.setMchTypeCodes(hardTypeInfo.getHardTypeList());
@@ -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);
}
@@ -190,6 +190,19 @@ public interface HardOrderMapper extends PjcloudBaseMapper<HardOrderEntity> {
List<HardOrderVo> scopeList(@Param("dto") HardOrderDto dto, DataScope dataScope);
/**
* 人员预约统计专用订单查询,只读取 hard_order 主表列。
*
* <p>统计聚合不使用设备、最新售后、付款人和发票等展示关联,这里省去 {@link #scopeList} 的
* 全部 join 与相关子查询,降低大日期范围统计的订单加载开销;返回映射结构与 {@link #scopeList}
* 一致,关联展示列置空。查询列保留 merchant_dept_id 供数据权限拦截器包装部门过滤。</p>
*
* @param dto 预约订单筛选条件(商户范围、订单状态、预约或核销日期等)
* @param dataScope 保留的数据范围参数;当前统计调用传 {@code null}
* @return 当前租户内符合筛选条件的订单列表,统计筛选口径与订单列表一致
*/
List<HardOrderVo> scopeListReservationStats(@Param("dto") HardOrderDto dto, DataScope dataScope);
/**
* 按订单列表完整筛选条件统计订单金额、商品份数及日期餐段明细。
*
@@ -119,13 +119,14 @@ public interface HardAfterSaleService extends IService<HardAfterSaleEntity> {
Boolean disagreeRefund(Long userId, HardDisagreeDto dto);
/**
* 将人工审核完成的钱包退款冲正并退回申请中,同时恢复退款前的库存占用和订单聚合状态。
* 将人工审核完成的钱包退款冲正并退回申请中,默认同时恢复退款前的库存占用和订单聚合状态。
*
* @param userId 当前后台操作用户 ID
* @param id 售后单 ID
* @param deductStock 是否重新占用本次退款恢复的商品库存;false 时只冲正钱包并冻结本轮库存链路
* @return 资金、库存和售后状态均已恢复时返回 {@code true}
*/
Boolean returnToReview(Long userId, Long id);
Boolean returnToReview(Long userId, Long id, boolean deductStock);
/**
* 校验订单是否可以退款
@@ -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);
}
@@ -112,7 +112,7 @@ public interface HardOrderService extends IService<HardOrderEntity> {
void applyAppPermissions(List<HardOrderVo> orders, Long viewerUserId);
/**
* 配置只扩展食堂预约订单的 App 可见范围。
* 配置 App 订单可见范围:本人、食堂预约订单的付款人和有效被监护人,以及关系图谱用户的全部类型订单。
*/
void configureAppVisibility(HardOrderDto dto, Long viewerUserId);
@@ -63,12 +63,14 @@ import com.bsd.pjcloud.wallet.api.vo.WalletManageVo;
import com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.*;
import java.util.function.Supplier;
@@ -103,8 +105,17 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
/** 人工退款退回审核时重新占用食堂基础 SPU 库存的幂等业务号前缀。 */
private static final String RETURN_REVIEW_REOCCUPY_INVENTORY_BIZ_PREFIX = "HARD_AFTER_SALE_REVIEW_REOCCUPY:";
/** 退回审核后再次同意退款时恢复食堂基础 SPU 库存的幂等业务号前缀。 */
private static final String RETURN_REVIEW_RESTORE_INVENTORY_BIZ_PREFIX = "HARD_AFTER_SALE_REVIEW_RESTORE:";
/** 退回审核跳过库存重新占用时追加到售后日志的标记;再次同意退款凭该标记跳过库存恢复。 */
private static final String RETURN_REVIEW_STOCK_KEEP_MARKER = "(未扣减库存)";
/** 同意售后恢复食堂库存的幂等业务号前缀:按审核尝试轮次隔离,失败重试必须换号。 */
private static final String AGREE_RESTORE_INVENTORY_BIZ_PREFIX = "HARD_AFTER_SALE_AGREE_RESTORE:";
/** 同一售后单审核尝试轮次的计数键前缀,用于生成每次尝试独立的库存恢复幂等业务号。 */
private static final String AGREE_RESTORE_INVENTORY_CYCLE_KEY_PREFIX = "hard:after-sale:agree-inventory-cycle:";
/** 审核尝试轮次计数过期时间,需长于食堂库存幂等状态 7 天 TTL,保证换号始终先于旧状态失效。 */
private static final Duration AGREE_RESTORE_INVENTORY_CYCLE_TTL = Duration.ofDays(8);
private final HardAfterSaleLogMapper hardAfterSaleLogMapper;
private final HardOrderService hardOrderService;
@@ -120,6 +131,9 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
private final HardCarService hardCarService;
private final HardAfterSaleCarService hardAfterSaleCarService;
/** 仅用于审核尝试轮次计数;库存幂等状态与业务键规范由食堂库存服务维护。 */
private final StringRedisTemplate stringRedisTemplate;
/** 自动退款任务服务,用于申请受理、旧入口强制拦截和售后响应状态补充。 */
private final HardAutoRefundTaskService hardAutoRefundTaskService;
@@ -908,7 +922,6 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
HardAfterSaleEntity afterSale = validateAfterSaleAuditable(id);
HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(afterSale.getOrderId());
if (order == null) throw new I18nBizException(ErrorCodes.AFTER_SALE_DEVICE_ORDER_MISSING);
long returnReviewCount = countReturnToReviewOperations(id);
// 更新售后单的状态
HardAfterSaleEntity updateRefundObj = HardAfterSaleEntity.builder()
.id(id)
@@ -921,13 +934,13 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
order.getOrderStatus(), order.getOrderStatus(), HardSaleStatusEnum.COMPLETE.getContent());
// 食堂基础 SPU 库存恢复失败时禁止发起退款;渠道退款失败由库存幂等回调撤销本次恢复。
boolean canteenSpuInventoryOrder = usesCanteenSpuInventory(order);
if (canteenSpuInventoryOrder) {
String inventoryBizId = returnReviewCount > 0
? RETURN_REVIEW_RESTORE_INVENTORY_BIZ_PREFIX + id + ":" + returnReviewCount : null;
restoreRefundInventory(order, id, inventoryBizId);
// 上一轮退回审核若选择"未扣减库存",库存仍处于已恢复态:本轮只退款不再恢复,避免恢复两次。
boolean stockKeptByReview = isStockKeptByLastReturnToReview(id);
if (canteenSpuInventoryOrder && !stockKeptByReview) {
restoreRefundInventory(order, id, nextAgreeRestoreInventoryBizId(id));
}
getSelf().refund(updateRefundObj.getId());
if (!canteenSpuInventoryOrder) {
if (!canteenSpuInventoryOrder && !stockKeptByReview) {
restoreOrderInventory(order, id);
}
recomputeOrderRefundState(order.getId());
@@ -935,25 +948,29 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
}
@Override
public Boolean returnToReview(Long userId, Long id) {
public Boolean returnToReview(Long userId, Long id, boolean deductStock) {
HardAfterSaleEntity current = baseMapper.selectById(id);
if (current == null) {
throw new I18nBizException(ErrorCodes.AFTER_SALE_NOT_FOUND);
}
return withOrderRefundLock(current.getOrderId(), () -> getSelf().returnToReviewInTransaction(userId, id));
return withOrderRefundLock(current.getOrderId(),
() -> getSelf().returnToReviewInTransaction(userId, id, deductStock));
}
/**
* 在订单锁内冲正人工钱包退款,并恢复退款审核前的库存占用、售后状态和订单聚合状态。
* 食堂基础 SPU 具备跨服务幂等补偿,先重新占用库存再扣回钱包;普通库存沿用历史接口,
* 先完成可幂等恢复的钱包冲正,再执行库存扣减,失败后可按仍为完成的售后状态继续重试。
* deductStock=false 时跳过库存重新占用,库存停留在已恢复态,并在售后日志写入
* "未扣减库存"标记,后续再次同意该售后时凭标记跳过库存恢复,避免恢复两次。
*
* @param userId 当前后台操作用户 ID
* @param id 已完成人工审核的售后单 ID
* @param deductStock 是否重新占用本次退款恢复的商品库存
* @return 资金、库存和售后状态均恢复时返回 {@code true}
*/
@Transactional(rollbackFor = Exception.class)
public Boolean returnToReviewInTransaction(Long userId, Long id) {
public Boolean returnToReviewInTransaction(Long userId, Long id, boolean deductStock) {
HardAfterSaleEntity afterSale = baseMapper.selectById(id);
if (afterSale == null) {
throw new I18nBizException(ErrorCodes.AFTER_SALE_NOT_FOUND);
@@ -977,15 +994,21 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
throw new RuntimeException("仅钱包支付订单支持退回审核,第三方支付退款无法自动扣回");
}
long reviewCycle = completedReturnCount + 1;
String inventoryBizId = RETURN_REVIEW_REOCCUPY_INVENTORY_BIZ_PREFIX + id + ":" + reviewCycle;
boolean canteenSpuInventoryOrder = usesCanteenSpuInventory(order);
if (canteenSpuInventoryOrder) {
reoccupyRefundInventory(order, id, inventoryBizId);
}
rollbackWalletRefund(id);
if (!canteenSpuInventoryOrder) {
reoccupyRefundInventory(order, id, inventoryBizId);
if (deductStock) {
// 保留先重新占用库存再扣回钱包的原顺序:食堂跨服务幂等可补偿,普通库存先钱包冲正再扣减。
String inventoryBizId = RETURN_REVIEW_REOCCUPY_INVENTORY_BIZ_PREFIX + id + ":" + (completedReturnCount + 1);
if (canteenSpuInventoryOrder) {
reoccupyRefundInventory(order, id, inventoryBizId);
}
rollbackWalletRefund(id);
if (!canteenSpuInventoryOrder) {
reoccupyRefundInventory(order, id, inventoryBizId);
}
} else {
// 跳过库存重新占用:库存停留在已恢复态,本轮只冲正钱包;日志写入未扣减库存标记,
// 供后续再次同意时跳过库存恢复,保证整轮退回-重审的库存净效果仍为一次恢复。
rollbackWalletRefund(id);
}
LambdaUpdateWrapper<HardAfterSaleEntity> updateWrapper = new LambdaUpdateWrapper<>();
@@ -999,9 +1022,10 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
if (baseMapper.update(null, updateWrapper) != 1) {
throw new RuntimeException("退回售后审核状态失败");
}
String returnToReviewContent = HardAfterSaleOperateTypeEnum.ADMIN_RETURN_TO_REVIEW.getContent()
+ (deductStock ? "" : RETURN_REVIEW_STOCK_KEEP_MARKER);
insertAfterSaleLog(id, userId, HardAfterSaleOperateTypeEnum.ADMIN_RETURN_TO_REVIEW.getType(),
HardSaleStatusEnum.COMPLETE.getStatus(), HardSaleStatusEnum.APPLY.getStatus(),
HardAfterSaleOperateTypeEnum.ADMIN_RETURN_TO_REVIEW.getContent());
HardSaleStatusEnum.COMPLETE.getStatus(), HardSaleStatusEnum.APPLY.getStatus(), returnToReviewContent);
recomputeOrderRefundState(order.getId());
return true;
}
@@ -1032,6 +1056,26 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
HardAfterSaleOperateTypeEnum.ADMIN_RETURN_TO_REVIEW.getType()));
}
/**
* 判断该售后单最近一次退回审核是否跳过了库存重新占用。
*
* <p>跳过时库存停留在已恢复态,后续再次同意只退款不恢复库存,保证整轮退回-重审
* 的库存净效果仍为一次恢复。标记持久化在售后日志 content 后缀,事务回滚随之撤销。</p>
*
* @param afterSaleId 售后单 ID
* @return 最近一次退回审核未扣减库存时返回 {@code true}
*/
private boolean isStockKeptByLastReturnToReview(Long afterSaleId) {
HardAfterSaleLogEntity lastReturn = hardAfterSaleLogMapper.selectOne(new LambdaQueryWrapper<HardAfterSaleLogEntity>()
.eq(HardAfterSaleLogEntity::getAfterSaleId, afterSaleId)
.eq(HardAfterSaleLogEntity::getOperateType,
HardAfterSaleOperateTypeEnum.ADMIN_RETURN_TO_REVIEW.getType())
.orderByDesc(HardAfterSaleLogEntity::getId)
.last("limit 1"));
return lastReturn != null && lastReturn.getContent() != null
&& lastReturn.getContent().endsWith(RETURN_REVIEW_STOCK_KEEP_MARKER);
}
@Override
public Boolean disagreeRefund(Long userId, HardDisagreeDto dto) {
HardAfterSaleEntity current = baseMapper.selectById(dto.getId());
@@ -2016,6 +2060,24 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
&& ObjectUtil.equal(parentCar.getTakeOutSettingJson().getCartScene(), DAILY_MENU_CART_SCENE));
}
/**
* 为本次同意售后分配独立的食堂库存恢复幂等业务号。
*
* <p>食堂库存服务的幂等状态机会把失败回滚后的业务号标记为 ROLLED_BACK 终态,
* 同业务号后续恢复请求会被静默跳过(返回成功但不再回补库存)。后台强化退款每次
* 新建售后单天然换号;人工审核复用同一售后单,必须按审核尝试轮次隔离业务号:
* 同一事务内轮次固定(远程重试不重复恢复库存),失败重试自增换号(不被终态吞掉)。</p>
*
* @param afterSaleId 本次同意的售后单 ID
* @return 本轮库存恢复幂等业务号
*/
private String nextAgreeRestoreInventoryBizId(Long afterSaleId) {
String cycleKey = AGREE_RESTORE_INVENTORY_CYCLE_KEY_PREFIX + afterSaleId;
Long cycle = stringRedisTemplate.opsForValue().increment(cycleKey);
stringRedisTemplate.expire(cycleKey, AGREE_RESTORE_INVENTORY_CYCLE_TTL);
return AGREE_RESTORE_INVENTORY_BIZ_PREFIX + afterSaleId + ":" + cycle;
}
/**
* 恢复每日菜谱或机器收银本次售后占用的基础 SPU 库存。
*
@@ -103,6 +103,15 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
/** 有效预约订单状态,只统计已支付待核销和已完成核销的订单。 */
private static final List<String> VALID_RESERVATION_ORDER_STATUSES = HardOrderStatusEnum.getPaySuccessCodes();
/**
* 人员预约统计的有效订单与退款净商品一次性加载结果。
*
* <p>统计链路原先在订单保留和菜品聚合两个阶段分别执行同一有效商品/核销事实查询,
* 快照把两份数据合并为一次查询结果,供每日聚合与范围总计共用。</p>
*/
private record EffectiveReservationSnapshot(List<HardOrderVo> orders, Map<Long, List<HardCarEntity>> carMap) {
}
/** 菜品预约聚合 Mapper。 */
private final HardCarMapper hardCarMapper;
@@ -271,7 +280,8 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
*
* <p>统计先复用用户预约列表的权限用户、有效订单和状态筛选口径,再读取完成退款扣减后的有效商品。
* 已核销筛选直接复用菜品销售统计的核销事实,保证人数、订单、份数和金额不会因整行状态或分页产生偏差。
* 菜品分组名称按 canteen_spu 当前数据解析,避免商户在每日菜谱中改菜名后同一商品按历史快照拆成多行。</p>
* 菜品分组名称按 canteen_spu 当前数据解析,避免商户在每日菜谱中改菜名后同一商品按历史快照拆成多行。
* 有效订单与退款净商品通过同一快照一次加载,避免聚合阶段重复执行重查询。</p>
*
* @param query 用户、身份、日期范围、食堂、餐段和状态筛选条件
* @return 按日期升序排列并包含范围总计的统计结果
@@ -282,11 +292,13 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
List<LocalDate> queryDates = getQueryDates(query);
List<UserVO> users = fetchAllUsers(query);
List<Long> userIds = users.stream().map(UserVO::getUserId).filter(Objects::nonNull).distinct().toList();
List<HardOrderVo> orders = fetchReservationOrders(userIds, query);
EffectiveReservationSnapshot snapshot = loadEffectiveReservationSnapshot(
fetchRawReservationOrders(userIds, query), query);
List<HardOrderVo> orders = snapshot.orders();
List<HardCanteenUserApptVo> rows = filterRows(buildRows(users, query, queryDates, orders), query);
Map<Long, HardOrderVo> orderMap = orders.stream().filter(order -> order.getId() != null)
.collect(Collectors.toMap(HardOrderVo::getId, Function.identity(), (left, right) -> left));
Map<Long, List<HardCarEntity>> carMap = fetchReservationCars(orders, query);
Map<Long, List<HardCarEntity>> carMap = snapshot.carMap();
Map<Long, CanteenSpuEntity> currentSpuMap = fetchCurrentCanteenSpus(carMap);
Map<LocalDate, StatisticsBucket> dailyBuckets = new LinkedHashMap<>();
@@ -328,7 +340,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
*
* <p>订单先按商户范围、日期和餐段读取,再复用 PC 人员预约页的核销状态判定筛选用户日期行,
* 保证待核销、部分核销和已核销的协议值及统计日期口径一致。菜品分组名称与 PC 统计一致,
* 按 canteen_spu 当前数据解析。</p>
* 按 canteen_spu 当前数据解析。有效订单与退款净商品通过同一快照一次加载,避免重复执行重查询。</p>
*
* @param query 预约日期、餐段和核销状态筛选条件
* @param merchantIds 服务端解析出的可管理食堂主键集合,禁止使用客户端自行声明的范围
@@ -347,8 +359,9 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
HardOrderDto orderQuery = buildReservationOrderQuery(query, null);
orderQuery.setMerchantId(null);
orderQuery.setMchIds(scopedMerchantIds);
List<HardOrderVo> orders = retainEffectiveReservationOrders(
hardOrderMapper.scopeList(orderQuery, null), query);
EffectiveReservationSnapshot snapshot = loadEffectiveReservationSnapshot(
hardOrderMapper.scopeListReservationStats(orderQuery, null), query);
List<HardOrderVo> orders = snapshot.orders();
if (orders.isEmpty()) {
return new HardCanteenUserApptStatisticsVo();
}
@@ -361,7 +374,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
.filter(row -> row.getOrders() != null && !row.getOrders().isEmpty()).toList();
Map<Long, HardOrderVo> orderMap = orders.stream().filter(order -> order.getId() != null)
.collect(Collectors.toMap(HardOrderVo::getId, Function.identity(), (left, right) -> left));
Map<Long, List<HardCarEntity>> carMap = fetchReservationCars(orders, query);
Map<Long, List<HardCarEntity>> carMap = snapshot.carMap();
Map<Long, CanteenSpuEntity> currentSpuMap = fetchCurrentCanteenSpus(carMap);
Map<LocalDate, StatisticsBucket> dailyBuckets = new LinkedHashMap<>();
@@ -802,6 +815,30 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
return retainEffectiveReservationOrders(orders, query);
}
/**
* 按用户主键分批查询指定日期范围内的食堂预约订单,不做有效商品过滤。
*
* <p>统计链路随后统一加载有效订单与退款净商品快照,避免先在分批查询后逐次保留、
* 再对保留结果重复查询有效商品数据。订单查询使用统计专用轻量语句,只读 hard_order 主表列。</p>
*
* @param userIds 权限范围内的用户主键
* @param query 日期范围、食堂和餐段筛选条件
* @return 当前租户内符合条件的预约订单
*/
private List<HardOrderVo> fetchRawReservationOrders(List<Long> userIds, HardCanteenUserApptQueryDto query) {
if (userIds.isEmpty()) {
return List.of();
}
List<HardOrderVo> orders = new ArrayList<>();
for (int start = 0; start < userIds.size(); start += ORDER_USER_BATCH_SIZE) {
int end = Math.min(start + ORDER_USER_BATCH_SIZE, userIds.size());
HardOrderDto orderQuery = buildReservationOrderQuery(query,
new ArrayList<>(userIds.subList(start, end)));
orders.addAll(hardOrderMapper.scopeListReservationStats(orderQuery, null));
}
return orders;
}
/**
* 只保留完成退款后仍有有效商品的预约订单,并把订单展示金额改为退款后的净商品金额。
*
@@ -836,34 +873,71 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
.map(HardCarEntity::getTotalPrice).mapToLong(HardCanteenApptTotalServiceImpl::number).sum(),
(left, right) -> left, LinkedHashMap::new));
}
return retainOrdersByEffectiveTotalPrice(orders, effectiveTotalPriceMap);
}
/**
* 一次性加载有效预约订单及其退款后有效商品,供统计链路同时获得保留订单和菜品聚合数据。
*
* <p>原实现先在订单保留阶段查一次有效商品(或核销事实),聚合阶段再对保留后的订单重复同一查询,
* 大日期范围下重查询执行两遍直接放大线上耗时。这里按核销口径只查一次并派生两份数据,
* 聚合结果与原"保留 + 重新查询"链路逐行等价:已核销用销售统计事实,其他状态用退款净商品快照,
* 全额退款订单剔除、部分退款只保留净额。</p>
*
* @param orders 已按订单状态和业务日期查询出的预约订单
* @param query 当前人员页面筛选条件
* @return 有效订单与父购物车有效商品映射的统计快照
*/
private EffectiveReservationSnapshot loadEffectiveReservationSnapshot(List<HardOrderVo> orders,
HardCanteenUserApptQueryDto query) {
if (orders.isEmpty()) {
return new EffectiveReservationSnapshot(List.of(), Map.of());
}
if (isVerifiedFilter(query)) {
Set<Long> orderIds = orders.stream().map(HardOrderVo::getId)
.filter(Objects::nonNull).collect(Collectors.toSet());
List<HardCanteenReservationSaleFact> facts = fetchReservationVerifiedSaleFacts(
orders.stream().map(HardOrderVo::getUserId).filter(Objects::nonNull).distinct().toList(), query)
.stream().filter(fact -> fact.getOrderId() != null && orderIds.contains(fact.getOrderId())).toList();
Map<Long, Long> effectiveTotalPriceMap = facts.stream().collect(Collectors.groupingBy(
HardCanteenReservationSaleFact::getOrderId,
Collectors.summingLong(fact -> number(fact.getTotalPrice()))));
List<HardOrderVo> retainedOrders = retainOrdersByEffectiveTotalPrice(orders, effectiveTotalPriceMap);
Map<Long, HardOrderVo> retainedOrderMap = retainedOrders.stream().filter(order -> order.getId() != null)
.collect(Collectors.toMap(HardOrderVo::getId, Function.identity(), (left, right) -> left));
Map<Long, List<HardCarEntity>> carMap = facts.stream()
.filter(fact -> retainedOrderMap.containsKey(fact.getOrderId()))
.map(fact -> toEffectiveCar(fact, retainedOrderMap.get(fact.getOrderId())))
.collect(Collectors.groupingBy(HardCarEntity::getParentCarId, LinkedHashMap::new,
Collectors.toList()));
return new EffectiveReservationSnapshot(retainedOrders, carMap);
}
Map<Long, List<HardCarEntity>> carMap = fetchEffectiveReservationCars(orders);
Map<Long, Long> effectiveTotalPriceMap = orders.stream()
.filter(order -> order.getId() != null && order.getParentCarId() != null)
.filter(order -> carMap.containsKey(order.getParentCarId()))
.collect(Collectors.toMap(HardOrderVo::getId,
order -> carMap.get(order.getParentCarId()).stream()
.map(HardCarEntity::getTotalPrice).mapToLong(HardCanteenApptTotalServiceImpl::number).sum(),
(left, right) -> left, LinkedHashMap::new));
return new EffectiveReservationSnapshot(retainOrdersByEffectiveTotalPrice(orders, effectiveTotalPriceMap), carMap);
}
/**
* 按有效净额映射过滤订单并回写退款后净总价。
*
* @param orders 原始预约订单
* @param effectiveTotalPriceMap 订单主键到退款后净总价的映射
* @return 仍有有效商品的订单,总价已更新为净额
*/
private List<HardOrderVo> retainOrdersByEffectiveTotalPrice(List<HardOrderVo> orders,
Map<Long, Long> effectiveTotalPriceMap) {
return orders.stream().filter(order -> order.getId() != null)
.filter(order -> effectiveTotalPriceMap.containsKey(order.getId()))
.peek(order -> order.setTotalPrice(Math.toIntExact(effectiveTotalPriceMap.get(order.getId()))))
.toList();
}
/**
* 分批读取预约订单关联的退款后有效商品,避免统计请求生成超长父购物车 IN 条件。
*
* @param orders 当前筛选口径下的预约订单
* @param query 当前人员页面筛选条件;已核销时用于切换到销售统计事实
* @return 父购物车主键到有效商品快照的映射
*/
private Map<Long, List<HardCarEntity>> fetchReservationCars(List<HardOrderVo> orders,
HardCanteenUserApptQueryDto query) {
if (isVerifiedFilter(query)) {
Map<Long, HardOrderVo> orderMap = orders.stream().filter(order -> order.getId() != null)
.collect(Collectors.toMap(HardOrderVo::getId, Function.identity(), (left, right) -> left));
return fetchReservationVerifiedSaleFacts(
orders.stream().map(HardOrderVo::getUserId).filter(Objects::nonNull).distinct().toList(), query)
.stream().filter(fact -> fact.getOrderId() != null && orderMap.containsKey(fact.getOrderId()))
.map(fact -> toEffectiveCar(fact, orderMap.get(fact.getOrderId())))
.collect(Collectors.groupingBy(HardCarEntity::getParentCarId, LinkedHashMap::new,
Collectors.toList()));
}
return fetchEffectiveReservationCars(orders);
}
/**
* 按父购物车分批读取完成退款扣减后的预约商品。
*
@@ -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();
}
}
@@ -408,12 +408,14 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
/**
* 创建订单页面使用的数据权限配置,以商户部门字段限制当前用户可见订单。
* 与订单分页接口同口径关闭个人权限子句:订单 create_by 不会是后台账号,导出与列表只按商户部门过滤。
*
* @return 独立的数据权限参数,供单次分页查询使用
*/
private DataScope createOrderDataScope() {
DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL);
dataScope.setScopeDeptName("merchant_dept_id");
dataScope.setScopeUserName(null);
return dataScope;
}
@@ -584,7 +584,8 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
}
/**
* 只有食堂预约订单扩展到付款人和当前有效被监护人,其他订单继续只按本人查询。
* App 订单可见范围:本人订单、食堂预约订单的付款人和有效被监护人,
* 以及关系图谱用户(与钱包相关人列表同口径)的全部类型订单。
*
* @param dto 订单查询参数
* @param viewerUserId 当前访问者用户ID
@@ -594,6 +595,8 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
dto.setUserId(null);
dto.setAppViewerUserId(viewerUserId);
dto.setAppWardUserIds(listActiveWardUserIds(viewerUserId, false));
// 关系图谱用户的所有类型订单对当前访问者可见
dto.setAppRelatedUserIds(listRelatedUserIds(viewerUserId, false));
}
/**
@@ -626,7 +629,8 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
}
/**
* 校验 App 订单读取权限。付款人始终可查看历史代订订单;监护关系只扩展当前食堂预约订单。
* 校验 App 订单读取权限。付款人始终可查看历史代订订单;监护关系只扩展当前食堂预约订单;
* 关系图谱用户的订单与列表可见口径保持一致,详情、购物车和售后同样允许查看。
*
* @param orderId 订单ID
* @param viewerUserId 当前访问者用户ID
@@ -644,6 +648,9 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
if (isCanteenReserveOrder(order) && listActiveWardUserIds(viewerUserId, false).contains(order.getUserId())) {
return;
}
if (listRelatedUserIds(viewerUserId, false).contains(order.getUserId())) {
return;
}
// throw new RuntimeException("无权查看该订单");
}
@@ -745,6 +752,37 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
return result.getData();
}
/**
* 查询当前用户在同一完整关系图谱中的其他用户 ID(与钱包相关人列表同口径)。
*
* <p>查询失败时按入参决定降级策略:App 列表/详情场景 failClosed=false,降级为空列表
* 以保留本人和被监护人可见性,不阻断订单查询。</p>
*
* @param userId 当前访问者用户ID
* @param failClosed true 表示查询失败直接抛错;false 表示降级为空列表
* @return 关系图谱用户 ID(不含本人,已去重);查询失败且允许降级时返回空列表
*/
private List<Long> listRelatedUserIds(Long userId, boolean failClosed) {
R<List<Long>> result;
try {
result = userRelationService.listRelatedUserIds(userId);
} catch (Exception exception) {
if (failClosed) {
throw new RuntimeException("查询用户关系图谱失败", exception);
}
log.warn("|{}|查询用户关系图谱异常,仅保留本人和被监护人订单权限,userId:|{}|,error:|{}|", TAG, userId, exception.getMessage());
return List.of();
}
if (result == null || !result.isOk() || result.getData() == null) {
if (failClosed) {
throw new I18nBizException(ErrorCodes.WALLET_USER_RELATION_FAIL);
}
log.warn("|{}|查询用户关系图谱失败,仅保留本人和被监护人订单权限,userId:|{}|", TAG, userId);
return List.of();
}
return result.getData().stream().filter(Objects::nonNull).distinct().toList();
}
private boolean isCanteenReserveOrder(HardOrderEntity order) {
return Objects.equals(order.getMerchantType(), MerchantTypeEnum.CANTEEN.getType())
&& Objects.equals(order.getSysType(), HardTypeEnum.CANTEEN.getType())
@@ -376,7 +376,12 @@
order by sale_stats.stat_time, sale_stats.order_id, sale_stats.product_id
</select>
<!-- 非已核销人员统计按父购物车读取同一套完成退款净商品,外层限制避免影响其他预约统计调用。 -->
<!--
非已核销人员统计按父购物车读取退款净商品。聚合口径与共享片段 apptEffectiveCarSource 完全一致,
但把父购物车 IN 条件下推到 hard_car 汇总和已完成售后聚合内部,先过滤后聚合,避免每次调用对
两张大表做全表 GROUP BY(外层 IN 过滤依赖优化器下推在线上不可靠)。
共享片段被 proudctMchQuery 等未加 @Param 的查询复用,不能参数化,因此这里独立维护同一套聚合 SQL。
-->
<select id="listEffectiveReservationCars" resultMap="hardCarMap">
select a.parent_car_id,
a.product_id,
@@ -387,11 +392,52 @@
a.total_price,
a.create_time,
a.del_flag
from <include refid="apptEffectiveCarSource"/>
where a.parent_car_id in
<foreach collection="parentCarIds" item="parentCarId" open="(" close=")" separator=",">
#{parentCarId}
</foreach>
from (
select original_car.parent_car_id,
original_car.product_id,
original_car.product_name,
original_car.product_pic,
original_car.product_price,
original_car.create_time,
'0' as del_flag,
greatest(original_car.product_count - ifnull(refund_car.product_count, 0), 0) as product_count,
greatest(original_car.total_price - ifnull(refund_car.total_price, 0), 0) as total_price
from (
select c.parent_car_id,
c.product_id,
max(c.product_name) as product_name,
max(c.product_pic) as product_pic,
max(c.product_price) as product_price,
min(c.create_time) as create_time,
sum(ifnull(c.product_count, 0)) as product_count,
sum(ifnull(c.total_price, 0)) as total_price
from hard_car c
where c.del_flag = '0'
and c.parent_car_id in
<foreach collection="parentCarIds" item="parentCarId" open="(" close=")" separator=",">
#{parentCarId}
</foreach>
group by c.parent_car_id, c.product_id
) original_car
left join (
select refund_item.parent_car_id,
refund_item.product_id,
sum(ifnull(refund_item.product_count, 0)) as product_count,
sum(ifnull(refund_item.total_price, 0)) as total_price
from hard_after_sale_car refund_item
inner join hard_after_sale refund_order on refund_order.id = refund_item.refund_id
and refund_order.del_flag = '0' and refund_order.status = '50'
where refund_item.del_flag = '0'
and refund_item.parent_car_id in
<foreach collection="parentCarIds" item="parentCarId" open="(" close=")" separator=",">
#{parentCarId}
</foreach>
group by refund_item.parent_car_id, refund_item.product_id
) refund_car
on refund_car.parent_car_id = original_car.parent_car_id
and refund_car.product_id = original_car.product_id
where original_car.product_count > ifnull(refund_car.product_count, 0)
) a
order by a.parent_car_id, a.product_id
</select>
@@ -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>
@@ -294,11 +294,20 @@
</if>
</sql>
<!-- App 仅对食堂预约订单扩展付款人和有效被监护人可见范围,其余订单仍只允许订单用户本人查看。 -->
<!--
App 订单可见范围:本人订单;关系图谱用户(与钱包相关人列表同口径)的全部类型订单;
食堂预约订单额外扩展付款人和有效被监护人。管理端查询不设置 appViewerUserId,本片段不生效。
-->
<sql id="appVisibilityWhere">
<if test="dto.appViewerUserId != null">
and (
a.user_id = #{dto.appViewerUserId}
<if test="dto.appRelatedUserIds != null and dto.appRelatedUserIds.size() != 0">
or a.user_id in
<foreach collection="dto.appRelatedUserIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
or (
a.merchant_type = '1'
and a.sys_type = '2'
@@ -447,6 +456,8 @@
merchant_dept_id 必须保留在查询列中:数据权限拦截器对部门权限用户包一层
SELECT * FROM (本查询) temp_data_scope WHERE temp_data_scope.merchant_dept_id IN (...),
缺少该列会直接 SQL 报错;总数统计走分页插件自动 count,基于数据权限改写后的 SQL 生成,同样带权限。
create_by 有意不查询:管理端入口(prepareAdminOrderQuery / noRecogPageJoin / 导出 createOrderDataScope)
已 setScopeUserName(null) 关闭个人权限子句,不会拼出 temp_data_scope.create_by。
-->
<select id="scopePageIds" resultType="com.bsd.pjcloud.hard.api.vo.order.HardOrderVo">
select a.id,
@@ -594,6 +605,35 @@
<include refid="pageList"/>
</select>
<!--
人员预约统计专用订单查询:只读 hard_order 主表列,不关联设备、最新售后、付款人和发票子查询,
避免大日期范围统计为每笔订单额外执行展示类相关子查询。查询列复用 selectA 并把关联展示列
显式置空,保持与 scopeList 相同的返回映射结构;merchant_dept_id 保留供数据权限拦截器包装部门过滤。
-->
<select id="scopeListReservationStats" resultType="com.bsd.pjcloud.hard.api.vo.order.HardOrderVo">
<include refid="selectA"/>
null as hardName,
null as hardType,
null as hardTypeDeptId,
null as afterSaleId,
null as afterSaleNo,
null as payerUserId,
null as latestInvoiceTaskId,
null as latestInvoiceStatus,
null as latestInvoicePrintNo,
null as latestInvoiceSourceHardId,
null as latestVoucherTaskId,
null as latestVoucherStatus,
null as latestVoucherPrintNo,
null as latestVoucherSourceHardId
from hard_order a
<where>
<include refid="whereA"/>
<include refid="appVisibilityWhere"/>
</where>
<include refid="pageListOrder"/>
</select>
<!--
订单统计与管理端分页共用 whereA、设备关联条件和数据权限。
内层先按订单聚合购物车,避免一笔订单含多行商品时重复累计订单金额。
@@ -25,6 +25,9 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
@@ -182,6 +185,25 @@ class HardCanteenApptTotalServiceImplTest {
assertEquals("当前名称", result.getDaily().get(0).getDishes().get(0).getProductName());
}
/**
* 验证统计请求只执行一次有效商品重查询并改用轻量订单查询:
* 订单保留与菜品聚合共用同一快照,不再对同一批订单重复查询有效商品,
* 也不再走带发票/售后子查询的完整订单列表语句。
*/
@Test
void loadsEffectiveCarsOnlyOncePerStatistics() {
stubReservationFlow(List.of(
car(201L, PRODUCT_ID, "套餐A", null, 1, 1000L),
car(202L, PRODUCT_ID, "套餐A", null, 2, 2000L)));
HardCanteenUserApptStatisticsVo result = service.appUserReservationDailyStatistics(query(), List.of(1L));
assertEquals(3000L, result.getTotal().getTotalPrice());
verify(hardCarMapper, times(1)).listEffectiveReservationCars(anyList());
verify(hardOrderMapper, times(1)).scopeListReservationStats(any(), any());
verify(hardOrderMapper, never()).scopeList(any(), any());
}
/**
* 搭建 App 统计链路:一笔用户、两笔同日预约订单,每笔订单挂一份商品快照。
*
@@ -189,7 +211,7 @@ class HardCanteenApptTotalServiceImplTest {
*/
private void stubReservationFlow(List<HardCarEntity> cars) {
List<HardOrderVo> orders = List.of(order(101L, 201L), order(102L, 202L));
when(hardOrderMapper.scopeList(any(), any())).thenReturn(orders);
when(hardOrderMapper.scopeListReservationStats(any(), any())).thenReturn(orders);
when(hardCarMapper.listEffectiveReservationCars(anyList())).thenReturn(cars);
UserVO user = new UserVO();
user.setUserId(USER_ID);
@@ -0,0 +1,123 @@
package com.bsd.pjcloud.hard.service.impl.order;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.bsd.pjcloud.admin.api.feign.RemoteUserRelationService;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Answers.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* App 订单可见性扩展测试:关系图谱用户(与钱包相关人列表同口径)的全部类型订单对当前访问者可见,
* 关系服务异常时降级为空列表,不阻断本人和被监护人可见范围。
*/
class HardOrderServiceImplAppVisibilityTest {
/** 当前访问者用户 ID。 */
private static final Long VIEWER_ID = 100L;
/** UPMS 用户关系服务,提供监护人和关系图谱查询。 */
private final RemoteUserRelationService userRelationService = mock(RemoteUserRelationService.class);
/** 订单 Mapper,用于单单查看权限校验时的订单读取。 */
private final HardOrderMapper hardOrderMapper = mock(HardOrderMapper.class);
/** 被测订单服务,仅执行真实方法,未涉及的庞大依赖保持空值。 */
private final HardOrderServiceImpl service = mock(HardOrderServiceImpl.class, CALLS_REAL_METHODS);
/**
* 为被测服务注入可见性配置与单单校验实际访问的依赖。
*/
@BeforeEach
void setUp() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"),
HardOrderEntity.class);
ReflectionTestUtils.setField(service, "baseMapper", hardOrderMapper);
ReflectionTestUtils.setField(service, "userRelationService", userRelationService);
}
/**
* 验证可见性配置清空前端传入的 userId,并注入访问者、被监护人和关系图谱用户;
* 关系图谱 ID 会过滤 null 并去重。
*/
@Test
void configureAppVisibilityInjectsRelatedUserIds() {
when(userRelationService.listActiveGuardianWardUserIds(VIEWER_ID)).thenReturn(R.ok(List.of(200L)));
when(userRelationService.listRelatedUserIds(VIEWER_ID)).thenReturn(R.ok(Arrays.asList(300L, null, 300L, 400L)));
HardOrderDto dto = new HardOrderDto();
dto.setUserId(999L);
service.configureAppVisibility(dto, VIEWER_ID);
assertNull(dto.getUserId());
assertEquals(VIEWER_ID, dto.getAppViewerUserId());
assertEquals(List.of(200L), dto.getAppWardUserIds());
assertEquals(List.of(300L, 400L), dto.getAppRelatedUserIds());
}
/**
* 关系图谱查询抛出异常时降级为空列表,不阻断列表查询,被监护人可见范围保持不变。
*/
@Test
void configureAppVisibilityDegradesToEmptyOnRelationException() {
when(userRelationService.listActiveGuardianWardUserIds(VIEWER_ID)).thenReturn(R.ok(List.of(200L)));
when(userRelationService.listRelatedUserIds(VIEWER_ID)).thenThrow(new RuntimeException("upms 不可用"));
HardOrderDto dto = new HardOrderDto();
assertDoesNotThrow(() -> service.configureAppVisibility(dto, VIEWER_ID));
assertEquals(List.of(200L), dto.getAppWardUserIds());
assertEquals(List.of(), dto.getAppRelatedUserIds());
}
/**
* 关系图谱查询返回业务失败时同样降级为空列表。
*/
@Test
void configureAppVisibilityDegradesToEmptyOnRelationBusinessFailure() {
when(userRelationService.listActiveGuardianWardUserIds(VIEWER_ID)).thenReturn(R.ok(List.of()));
when(userRelationService.listRelatedUserIds(VIEWER_ID)).thenReturn(R.failed("查询用户关系失败"));
HardOrderDto dto = new HardOrderDto();
service.configureAppVisibility(dto, VIEWER_ID);
assertEquals(List.of(), dto.getAppRelatedUserIds());
}
/**
* 非食堂预约订单(如商城订单)不属于本人、付款人和被监护人扩展范围时,
* 单单查看权限通过关系图谱分支放行,与列表可见口径保持一致。
*/
@Test
void validateAppOrderViewAllowsRelatedUserOrder() {
HardOrderEntity order = HardOrderEntity.builder()
.id(1L)
.userId(300L)
.orderType(HardOrderTypeEnum.BELOW.getType())
.build();
when(hardOrderMapper.selectById(1L)).thenReturn(order);
when(userRelationService.listRelatedUserIds(VIEWER_ID)).thenReturn(R.ok(List.of(300L)));
assertDoesNotThrow(() -> service.validateAppOrderView(1L, VIEWER_ID));
verify(userRelationService, times(1)).listRelatedUserIds(VIEWER_ID);
}
}
@@ -0,0 +1,46 @@
package com.bsd.pjcloud.admin.api.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.Objects;
/**
* 学校和学号组合键,供跨服务批量定位学生身份使用。
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "学校和学号组合键")
public class StudentSchoolNoDTO implements Serializable {
@Schema(description = "学校名称")
private String schoolName;
@Schema(description = "学号")
private String studentNo;
/**
* 组合键去重时同时比较学校名称和学号,避免仅按单个字段误伤。
* @param o 待比较对象
* @return 学校名称和学号均一致时返回真
*/
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof StudentSchoolNoDTO that)) {
return false;
}
return Objects.equals(schoolName, that.schoolName) && Objects.equals(studentNo, that.studentNo);
}
@Override
public int hashCode() {
return Objects.hash(schoolName, studentNo);
}
}
@@ -0,0 +1,29 @@
package com.bsd.pjcloud.admin.api.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* 学校和学号批量定位命中的学生用户资料,携带定位键便于调用方回填到对应 Excel 行。
*/
@Data
@Schema(description = "学校和学号定位的学生用户资料")
public class StudentUserMatchDTO implements Serializable {
@Schema(description = "学校名称,与请求组合键一致")
private String schoolName;
@Schema(description = "学号,与请求组合键一致")
private String studentNo;
@Schema(description = "用户ID")
private Long userId;
@Schema(description = "姓名")
private String name;
@Schema(description = "部门ID")
private Long deptId;
}
@@ -81,6 +81,12 @@ public class UserDTO extends SysUser implements BaseIsIncludeTreeInterface, Base
@Schema(description = "新密码")
private String newpassword1;
/**
* 密码是否已经加密:true 时落库直接使用 password 现值,仅供批量导入等预加密场景,普通调用保持为空。
*/
@Schema(description = "密码是否已加密", hidden = true)
private Boolean passwordEncoded;
/**
* 根据名字或者手机号来查询
*/
@@ -20,6 +20,8 @@
package com.bsd.pjcloud.admin.api.feign;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.StudentSchoolNoDTO;
import com.bsd.pjcloud.admin.api.dto.StudentUserMatchDTO;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
import com.bsd.pjcloud.admin.api.dto.UserDeptSimpleInfo;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
@@ -160,6 +162,24 @@ public interface RemoteUserService {
R<UserInfo> getStudentBySchoolNameAndStudentNo(@RequestParam("schoolName") String schoolName,
@RequestParam("studentNo") String studentNo);
/**
* 按手机号批量查询用户,供大文件 Excel 导入同步校验阶段一次定位多行用户。
*
* @param mobiles 去重后的手机号列表,调用方应控制单次数量
* @return 命中的用户列表;未命中的手机号不返回记录
*/
@PostMapping("/user/get-users-by-mobiles")
R<List<SysUser>> getUsersByMobiles(@RequestBody List<String> mobiles);
/**
* 按学校和学号组合键批量查询学生用户,供大文件 Excel 导入同步校验阶段一次定位多行学生。
*
* @param keys 学校和学号组合键列表,调用方应控制单次数量
* @return 命中的学生用户资料,携带定位键便于回填;未命中的组合键不返回记录
*/
@PostMapping("/user/get-students-by-school-and-no")
R<List<StudentUserMatchDTO>> getStudentsBySchoolNameAndStudentNoBatch(@RequestBody List<StudentSchoolNoDTO> keys);
/**
* 根据用户ID获取用户
*/
@@ -21,6 +21,7 @@ package com.bsd.pjcloud.admin.controller;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.collection.ListUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
@@ -28,6 +29,8 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.ForgotPasswordDTO;
import com.bsd.pjcloud.admin.api.dto.StudentSchoolNoDTO;
import com.bsd.pjcloud.admin.api.dto.StudentUserMatchDTO;
import com.bsd.pjcloud.admin.api.dto.SysUserDeleteDto;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
import com.bsd.pjcloud.admin.api.dto.UserDeptSimpleInfo;
@@ -81,6 +84,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.Objects;
@@ -181,6 +185,102 @@ public class SysUserController {
return user == null ? R.failed("学生用户不存在") : R.ok(userService.findUserInfo(user));
}
/**
* 按手机号批量查询用户,供钱包等业务的大文件 Excel 导入在同步校验阶段一次定位多行用户,替代逐行远程调用。
*
* @param mobiles 手机号列表,服务端按批次查询
* @return 命中的用户列表;未命中的手机号不返回记录
*/
@PostMapping("/get-users-by-mobiles")
public R<List<SysUser>> getUsersByMobiles(@RequestBody List<String> mobiles) {
if (CollectionUtils.isEmpty(mobiles)) {
return R.ok(List.of());
}
List<String> distinctMobiles = mobiles.stream()
.filter(StrUtil::isNotBlank)
.map(String::trim)
.distinct()
.toList();
List<SysUser> users = new ArrayList<>();
// 按 1000 一批执行 IN 查询,避免超出 SQL 长度或全表扫描
for (List<String> batch : ListUtil.partition(distinctMobiles, 1000)) {
users.addAll(userService.list(Wrappers.<SysUser>query().lambda().in(SysUser::getPhone, batch)));
}
return R.ok(users);
}
/**
* 按学校和学号组合键批量查询学生用户,供大文件 Excel 导入在同步校验阶段一次定位多行学生。
*
* @param keys 学校和学号组合键列表,服务端按学号分批查询后精确匹配学校名称
* @return 命中的学生定位资料,携带定位键便于调用方回填;未命中的组合键不返回记录
*/
@PostMapping("/get-students-by-school-and-no")
public R<List<StudentUserMatchDTO>> getStudentsBySchoolNameAndStudentNoBatch(@RequestBody List<StudentSchoolNoDTO> keys) {
if (CollectionUtils.isEmpty(keys)) {
return R.ok(List.of());
}
Set<StudentSchoolNoDTO> distinctKeys = keys.stream()
.filter(key -> key != null && StrUtil.isNotBlank(key.getSchoolName()) && StrUtil.isNotBlank(key.getStudentNo()))
.map(key -> new StudentSchoolNoDTO(StrUtil.trim(key.getSchoolName()), StrUtil.trim(key.getStudentNo())))
.collect(Collectors.toSet());
if (distinctKeys.isEmpty()) {
return R.ok(List.of());
}
// 先按学号分批缩小范围,再在内存中按学校和学号精确匹配,避免生成超长 OR 条件
List<SysUserStudentEntity> students = new ArrayList<>();
for (List<String> batch : ListUtil.partition(distinctKeys.stream().map(StudentSchoolNoDTO::getStudentNo).distinct().toList(), 1000)) {
students.addAll(sysUserStudentService.list(Wrappers.<SysUserStudentEntity>query().lambda()
.in(SysUserStudentEntity::getStudentNo, batch)));
}
Map<String, SysUserStudentEntity> studentByKey = students.stream()
.filter(student -> StrUtil.isNotBlank(student.getSchoolName()))
.collect(Collectors.toMap(
student -> buildSchoolStudentKey(StrUtil.trim(student.getSchoolName()), StrUtil.trim(student.getStudentNo())),
Function.identity(), (first, ignored) -> first));
List<Long> userIds = distinctKeys.stream()
.map(key -> studentByKey.get(buildSchoolStudentKey(key.getSchoolName(), key.getStudentNo())))
.filter(Objects::nonNull)
.map(SysUserStudentEntity::getUserId)
.distinct()
.toList();
Map<Long, SysUser> userById = userIds.isEmpty() ? Map.of()
: userService.listByIds(userIds).stream()
.collect(Collectors.toMap(SysUser::getUserId, Function.identity(), (first, ignored) -> first));
List<StudentUserMatchDTO> matches = distinctKeys.stream()
.map(key -> {
SysUserStudentEntity student = studentByKey.get(buildSchoolStudentKey(key.getSchoolName(), key.getStudentNo()));
if (student == null) {
return null;
}
SysUser user = userById.get(student.getUserId());
if (user == null) {
return null;
}
StudentUserMatchDTO match = new StudentUserMatchDTO();
match.setSchoolName(key.getSchoolName());
match.setStudentNo(key.getStudentNo());
match.setUserId(user.getUserId());
match.setName(user.getName());
match.setDeptId(user.getDeptId());
return match;
})
.filter(Objects::nonNull)
.toList();
return R.ok(matches);
}
/**
* 生成学校和学号组合键,用于批量学生查询的内存匹配,不与业务协议交互。
*
* @param schoolName 学校名称
* @param studentNo 学号
* @return 保留空值语义的组合键
*/
private String buildSchoolStudentKey(String schoolName, String studentNo) {
return StrUtil.nullToEmpty(schoolName) + "\u0000" + StrUtil.nullToEmpty(studentNo);
}
@Inner
@GetMapping("/get-name-or-mobile")
public R<List<UserDeptSimpleInfo>> getUserByNameOrMobile(@RequestParam("keyword") String keyword) {
@@ -124,6 +124,15 @@ public interface SysUserService extends IService<SysUser> {
*/
Long saveUserWithoutAudit(UserDTO userDto);
/**
* 批量导入场景专用保存:操作人角色快照只解析一次后逐行落库,单条保存语义与 {@link #saveUser} 完全一致。
* <p>本方法不自带事务边界,由调用方按批控制提交;配合 {@code UserDTO.passwordEncoded}
* 可复用调用方预先加密好的密码,避免逐行重复 BCrypt。</p>
*
* @param users 已通过整表校验的用户创建参数列表
*/
void saveImportUsers(List<UserDTO> users);
/**
* 生成未被占用的系统用户登录账号。
*/
@@ -39,6 +39,7 @@ import com.bsd.pjcloud.admin.service.SysUserRelationTypeService;
import com.bsd.pjcloud.admin.service.SysUserService;
import com.bsd.pjcloud.admin.utils.excel.ExcelTool;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.core.constant.CommonConstants;
import com.bsd.pjcloud.common.core.constant.CommonRoleConstant;
@@ -53,7 +54,11 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.PictureData;
import org.springframework.beans.BeanUtils;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.validation.BindingResult;
import org.springframework.web.multipart.MultipartFile;
@@ -106,6 +111,15 @@ public class SysUserImportExportServiceImpl implements SysUserImportExportServic
/** 带人脸图片的后台用户导入任务业务 Redis Key 命名空间。 */
private static final String FACE_USER_IMPORT_REDIS_KEY = "user:import:face";
/** 后台用户导入每批独立事务提交的行数,控制事务与锁持有时长,并让钱包创建事件按批错开。 */
private static final int IMPORT_BATCH_SIZE = 500;
/** 已存在用户名批量查询的分批大小,避免单条 IN 条件过长。 */
private static final int USERNAME_QUERY_BATCH_SIZE = 1000;
/** 导入密码加密器,与用户落库服务同为默认强度 BCrypt,供事务外并行预加密初始密码。 */
private static final PasswordEncoder IMPORT_PASSWORD_ENCODER = new BCryptPasswordEncoder();
/** 用户核心服务,用于用户查询、账号生成和完整用户创建。 */
private final SysUserService sysUserService;
@@ -139,6 +153,9 @@ public class SysUserImportExportServiceImpl implements SysUserImportExportServic
/** 通用异步事务任务执行器,用于在同步整表校验通过后后台统一落库并报告进度。 */
private final AsyncTaskExecutorService asyncTaskExecutorService;
/** 编程式事务模板,用于派生按批独立提交的导入事务。 */
private final TransactionTemplate transactionTemplate;
/**
* 导入后台用户基础信息,并以 Excel 行号汇总业务校验错误;整表无错误后才统一写入。
*
@@ -156,11 +173,12 @@ public class SysUserImportExportServiceImpl implements SysUserImportExportServic
List<SysRole> roleList = sysRoleService.list();
List<SysPost> postList = sysPostService.list();
Map<String, Long> usernameCountMap = countNonBlankValues(excelVOList.stream().map(UserExcelVO::getUsername).toList());
Set<String> existingUsernames = loadExistingUsernames(excelVOList.stream()
.map(UserExcelVO::getUsername).filter(StrUtil::isNotBlank).distinct().toList());
Map<UserExcelVO, BackendUserImportContext> importContextMap = new HashMap<>();
for (UserExcelVO excel : excelVOList) {
Set<String> errorMessages = new HashSet<>();
boolean usernameExists = sysUserService.exists(Wrappers.<SysUser>lambdaQuery().eq(SysUser::getUsername, excel.getUsername()));
if (usernameExists) {
if (existingUsernames.contains(excel.getUsername())) {
errorMessages.add(MsgUtils.getMessage(ErrorCodes.SYS_USER_USERNAME_EXISTING, excel.getUsername()));
}
if (StrUtil.isNotBlank(excel.getUsername()) && usernameCountMap.getOrDefault(excel.getUsername(), 0L) > 1) {
@@ -197,12 +215,12 @@ public class SysUserImportExportServiceImpl implements SysUserImportExportServic
if (CollUtil.isNotEmpty(errorMessageList)) {
return R.failed(errorMessageList);
}
// 初始密码为各自手机号,先在事务外并行完成 BCrypt 加密,避免数万次加密串行拖长数据库事务。
Map<String, String> encodedPasswords = precomputePasswords(excelVOList);
TransactionTemplate batchTemplate = new TransactionTemplate(transactionTemplate.getTransactionManager());
batchTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
AsyncTaskProgressVO progress = asyncTaskExecutorService.submit(BACKEND_USER_IMPORT_REDIS_KEY, "后台用户导入",
excelVOList.size(), reporter -> excelVOList.forEach(excel -> {
BackendUserImportContext context = importContextMap.get(excel);
insertBackendUser(excel, context.department, context.roles, context.posts);
reporter.advance("正在导入后台用户:" + excel.getUsername());
}));
excelVOList.size(), reporter -> importBackendUsersInBatches(excelVOList, importContextMap, encodedPasswords, batchTemplate, reporter));
return R.ok(progress, "Excel校验通过,已开始后台导入");
}
@@ -690,14 +708,69 @@ public class SysUserImportExportServiceImpl implements SysUserImportExportServic
}
/**
* 创建普通后台用户,密码按原有导入协议初始化为手机号。
* 分批独立事务创建后台用户:每批一次提交,单批失败只回滚该批,已提交批次保留。
*
* @param excelVOList 整表校验通过的后台用户行
* @param importContextMap 各行解析好的部门、角色、岗位
* @param encodedPasswords 预加密的手机号密码密文
* @param batchTemplate 按批独立提交的事务模板
* @param reporter 任务进度报告器
*/
private void importBackendUsersInBatches(List<UserExcelVO> excelVOList, Map<UserExcelVO, BackendUserImportContext> importContextMap,
Map<String, String> encodedPasswords, TransactionTemplate batchTemplate, AsyncTaskProgressReporter reporter) {
for (int from = 0; from < excelVOList.size(); from += IMPORT_BATCH_SIZE) {
List<UserExcelVO> batch = excelVOList.subList(from, Math.min(from + IMPORT_BATCH_SIZE, excelVOList.size()));
batchTemplate.executeWithoutResult(status -> sysUserService.saveImportUsers(batch.stream()
.map(excel -> {
BackendUserImportContext context = importContextMap.get(excel);
return buildBackendUser(excel, context.department, context.roles, context.posts, encodedPasswords);
}).toList()));
batch.forEach(excel -> reporter.advance("正在导入后台用户:" + excel.getUsername()));
}
}
/**
* 分批查询库中已存在的用户名,替代逐行 exists 查询。
*
* @param usernames 去重后的待检用户名
* @return 已存在的用户名集合
*/
private Set<String> loadExistingUsernames(List<String> usernames) {
if (CollUtil.isEmpty(usernames)) {
return Set.of();
}
Set<String> existing = new HashSet<>();
for (int from = 0; from < usernames.size(); from += USERNAME_QUERY_BATCH_SIZE) {
List<String> batch = usernames.subList(from, Math.min(from + USERNAME_QUERY_BATCH_SIZE, usernames.size()));
existing.addAll(sysUserService.list(Wrappers.<SysUser>lambdaQuery().in(SysUser::getUsername, batch))
.stream().map(SysUser::getUsername).toList());
}
return existing;
}
/**
* 并行预计算导入用户的初始密码密文(初始密码按导入协议为各自手机号),把 BCrypt 开销移出数据库事务。
*
* @param excelVOList 整表校验通过的后台用户行
* @return 手机号到加密密文的映射
*/
private Map<String, String> precomputePasswords(List<UserExcelVO> excelVOList) {
return excelVOList.stream().map(UserExcelVO::getPhone).filter(StrUtil::isNotBlank).distinct()
.parallel().collect(Collectors.toMap(Function.identity(), IMPORT_PASSWORD_ENCODER::encode, (first, second) -> first));
}
/**
* 组装普通后台用户的创建参数,密码按原有导入协议初始化为手机号并复用预加密密文。
*
* @param excel 当前校验通过的后台用户行
* @param department 已匹配的部门
* @param roles 已匹配的角色
* @param posts 已匹配的岗位
* @param encodedPasswords 预加密的手机号密码密文
* @return 待落库的用户创建参数
*/
private void insertBackendUser(UserExcelVO excel, SysDept department, List<SysRole> roles, List<SysPost> posts) {
private UserDTO buildBackendUser(UserExcelVO excel, SysDept department, List<SysRole> roles, List<SysPost> posts,
Map<String, String> encodedPasswords) {
UserDTO user = new UserDTO();
user.setUsername(excel.getUsername());
user.setPhone(excel.getPhone());
@@ -707,11 +780,17 @@ public class SysUserImportExportServiceImpl implements SysUserImportExportServic
user.setLockFlag(parseLockFlag(excel.getLockFlag()));
user.setStaffingType(excel.getStaffingTypeParse());
user.setPasswordModifyTime(LocalDateTime.now());
user.setPassword(excel.getPhone());
String encodedPassword = encodedPasswords.get(excel.getPhone());
if (StrUtil.isNotBlank(encodedPassword)) {
user.setPassword(encodedPassword);
user.setPasswordEncoded(true);
} else {
user.setPassword(excel.getPhone());
}
user.setDeptId(department.getDeptId());
user.setPost(posts.stream().map(SysPost::getPostId).toList());
user.setRole(roles.stream().map(SysRole::getRoleId).toList());
sysUserService.saveUser(user);
return user;
}
/** 启用状态:启用/0 → 0;禁用/9 → 9;空默认启用 */
@@ -194,13 +194,46 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
@Override
@Transactional(rollbackFor = Exception.class)
public Long saveUser(UserDTO userDto) {
return saveUser(userDto, false);
return doSaveUser(userDto, false, resolveOperationRoles());
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long saveUserWithoutAudit(UserDTO userDto) {
return saveUser(userDto, true);
return doSaveUser(userDto, true, resolveOperationRoles());
}
@Override
public void saveImportUsers(List<UserDTO> users) {
if (CollUtil.isEmpty(users)) {
return;
}
// 批量导入期间操作人角色不会变化,快照只解析一次,避免逐行重复查询。
OperationRoleSnapshot snapshot = resolveOperationRoles();
for (UserDTO user : users) {
doSaveUser(user, false, snapshot);
}
}
/**
* 解析当前操作人的角色上下文,供一次或一批用户创建复用。
*
* @return 操作人角色快照;非管理员操作人附带固定的新增用户角色ID
*/
private OperationRoleSnapshot resolveOperationRoles() {
PjcloudUser applyUser = SecurityUtils.getUser();
List<SysRole> applyUserRoles = applyUser == null ? Collections.emptyList() : sysRoleService.findRolesByUserId(applyUser.getId());
boolean roleAdmin = CollUtil.isNotEmpty(applyUserRoles) && applyUserRoles.stream()
.anyMatch(role -> ObjectUtil.equals(role.getRoleCode(), CommonRoleConstant.ROLE_ADMIN));
return new OperationRoleSnapshot(applyUser != null, applyUserRoles, roleAdmin,
roleAdmin ? null : getNonAdminNewUserRoleIds());
}
/**
* 操作人角色快照:一次或一批用户创建期间,操作人的角色集合与权限结论保持不变。
*/
private record OperationRoleSnapshot(boolean hasApplyUser, List<SysRole> applyUserRoles, boolean roleAdmin,
List<Long> nonAdminNewRoleIds) {
}
/**
@@ -210,27 +243,23 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
*
* @param userDto 用户创建参数;普通后台创建场景中的角色可能被安全规则覆盖
* @param skipAudit 是否跳过新增用户审批,不影响角色选择权限
* @param snapshot 预先解析的操作人角色快照
* @return 新建用户ID
*/
private Long saveUser(UserDTO userDto, boolean skipAudit) {
private Long doSaveUser(UserDTO userDto, boolean skipAudit, OperationRoleSnapshot snapshot) {
sysUserIdentityService.validateDto(userDto);
SysUser sysUser = new SysUser();
BeanUtils.copyProperties(userDto, sysUser);
// 是否进入审批由当前操作人的专用角色控制,不能再通过部门管理员身份推断。
PjcloudUser applyUser = SecurityUtils.getUser();
List<SysRole> applyUserRoles = applyUser == null ? Collections.emptyList() : sysRoleService.findRolesByUserId(applyUser.getId());
boolean isRoleAdmin = CollUtil.isNotEmpty(applyUserRoles) && applyUserRoles.stream()
.anyMatch(role -> ObjectUtil.equals(role.getRoleCode(), CommonRoleConstant.ROLE_ADMIN));
if (!isRoleAdmin) {
if (!snapshot.roleAdmin()) {
// 请求中的角色ID不可信,只有明确具有管理员角色的操作人才能自选新增用户角色。
userDto.setRole(getNonAdminNewUserRoleIds());
userDto.setRole(snapshot.nonAdminNewRoleIds());
}
boolean needApproval = false;
if (skipAudit) {
sysUser.setProcessStatus(TaskCheckStatusEnum.RESULT.getCode());
sysUser.setProcessTime(LocalDateTime.now());
} else if (null != applyUser) {
needApproval = CollUtil.isNotEmpty(applyUserRoles) && applyUserRoles.stream()
} else if (snapshot.hasApplyUser()) {
needApproval = snapshot.applyUserRoles().stream()
.anyMatch(role -> ObjectUtil.equals(role.getRoleCode(), CommonRoleConstant.ADD_USER_NEED_CEHCK));
if (needApproval) {
userDto.setDeptIds(null);
@@ -244,7 +273,9 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
sysUser.setLockFlag(StrUtil.isBlank(userDto.getLockFlag()) ? UserStateEnum.NORMAL.getCode() : userDto.getLockFlag());
sysUser.setCreateBy(userDto.getUsername());
sysUser.setUpdateBy(userDto.getUsername());
sysUser.setPassword(ENCODER.encode(userDto.getPassword()));
// 批量导入等场景已在数据库事务外并行完成加密,这里直接复用,避免逐行重复 BCrypt 拖长事务。
sysUser.setPassword(Boolean.TRUE.equals(userDto.getPasswordEncoded())
? userDto.getPassword() : ENCODER.encode(userDto.getPassword()));
sysUser.setPasswordModifyTime(LocalDateTime.now());
sysUser.setPasswordExpireFlag(UserExpireEnum.EXPIRE.getCode());
baseMapper.insert(sysUser);
@@ -16,6 +16,19 @@ spring:
import:
- optional:nacos:application-@profiles.active@.yml
- optional:nacos:${spring.application.name}-@profiles.active@.yml
datasource:
druid:
# 远程库网络会掐断空闲约 10 秒的连接:借出前强制校验,杜绝拿到已被服务端断开的死连接。
test-on-borrow: true
test-while-idle: true
validation-query: SELECT 1
keep-alive: true
initial-size: 1
min-idle: 1
max-active: 16
time-between-eviction-runs-millis: 10000
min-evictable-idle-time-millis: 30000
keep-alive-between-time-runs-millis: 12000
qn:
@@ -1,10 +1,12 @@
package com.bsd.pjcloud.wallet.service.impl;
import cn.hutool.core.collection.ListUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.admin.api.dto.StudentSchoolNoDTO;
import com.bsd.pjcloud.admin.api.dto.StudentUserMatchDTO;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
@@ -25,6 +27,7 @@ import org.springframework.stereotype.Service;
import org.springframework.validation.BindingResult;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@@ -49,6 +52,12 @@ public class WalletActiveImportServiceImpl implements WalletActiveImportService
/** 允许导入的饭卡卡号长度,与既有开卡导入规则保持一致。 */
private static final Set<Integer> CARD_NUMBER_LENGTHS = Set.of(8, 10, 12);
/** 批量用户定位单次远程请求携带的最大键数量,防止 SQL 过长。 */
private static final int LOOKUP_BATCH_SIZE = 1000;
/** 单次导入同步返回的最大错误条数,超出部分以汇总行提示,避免大文件全错时响应体过大。 */
private static final int MAX_RETURNED_ERRORS = 1000;
/** 用户远程服务,用于按默认模板或学生身份条件精确定位系统用户。 */
private final RemoteUserService remoteUserService;
@@ -78,7 +87,7 @@ public class WalletActiveImportServiceImpl implements WalletActiveImportService
.filter(row -> !ObjectUtil.isAllEmpty(row.getUsername(), row.getUserMobile(), row.getCardNumber()))
.toList();
if (effectiveRows.isEmpty()) {
return errors.isEmpty() ? R.failed("Excel内容为空") : R.failed(errors);
return errors.isEmpty() ? R.failed("Excel内容为空") : R.failed(capErrors(errors));
}
effectiveRows.forEach(row -> {
@@ -88,6 +97,9 @@ public class WalletActiveImportServiceImpl implements WalletActiveImportService
});
Map<String, Long> phoneCounts = countValues(effectiveRows.stream().map(WalletImportActiveDto::getUserMobile).toList());
Map<String, Long> cardCounts = countValues(effectiveRows.stream().map(WalletImportActiveDto::getCardNumber).toList());
// 一次性批量定位全部用户,避免逐行 Feign 导致大文件同步校验超时
Map<String, List<SysUser>> usersByMobile = loadUsersByMobile(
effectiveRows.stream().map(WalletImportActiveDto::getUserMobile).toList());
List<ValidatedImportRow> validatedRows = new ArrayList<>();
for (WalletImportActiveDto row : effectiveRows) {
Set<String> rowErrors = new LinkedHashSet<>();
@@ -99,8 +111,12 @@ public class WalletActiveImportServiceImpl implements WalletActiveImportService
}
SysUser user = null;
if (StrUtil.isNotBlank(row.getUsername()) && StrUtil.isNotBlank(row.getUserMobile())) {
user = extractUser(remoteUserService.getUserByNameAndMobile(row.getUsername(), row.getUserMobile()));
if (user == null || !ObjectUtil.equals(user.getName(), row.getUsername())) {
List<SysUser> candidates = usersByMobile.get(row.getUserMobile());
user = candidates == null ? null : candidates.stream()
.filter(candidate -> ObjectUtil.equals(candidate.getName(), row.getUsername()))
.findFirst()
.orElse(null);
if (user == null) {
rowErrors.add("用户不存在");
}
}
@@ -127,7 +143,7 @@ public class WalletActiveImportServiceImpl implements WalletActiveImportService
.filter(row -> !ObjectUtil.isAllEmpty(row.getStudentName(), row.getSchoolName(), row.getStudentNo(), row.getCardNumber()))
.toList();
if (effectiveRows.isEmpty()) {
return errors.isEmpty() ? R.failed("Excel内容为空") : R.failed(errors);
return errors.isEmpty() ? R.failed("Excel内容为空") : R.failed(capErrors(errors));
}
effectiveRows.forEach(row -> {
@@ -139,6 +155,8 @@ public class WalletActiveImportServiceImpl implements WalletActiveImportService
Map<String, Long> studentCounts = countValues(effectiveRows.stream()
.map(row -> buildStudentKey(row.getSchoolName(), row.getStudentNo())).toList());
Map<String, Long> cardCounts = countValues(effectiveRows.stream().map(WalletStudentImportActiveDto::getCardNumber).toList());
// 一次性批量定位全部学生,避免逐行 Feign 导致大文件同步校验超时
Map<String, StudentUserMatchDTO> studentByKey = loadStudentsByKey(effectiveRows);
List<ValidatedImportRow> validatedRows = new ArrayList<>();
for (WalletStudentImportActiveDto row : effectiveRows) {
Set<String> rowErrors = new LinkedHashSet<>();
@@ -150,17 +168,17 @@ public class WalletActiveImportServiceImpl implements WalletActiveImportService
if (StrUtil.isNotBlank(row.getSchoolName()) && StrUtil.isNotBlank(row.getStudentNo()) && studentCounts.getOrDefault(studentKey, 0L) > 1) {
rowErrors.add("学生学校和学号 <" + row.getSchoolName() + " / " + row.getStudentNo() + "> 在Excel中重复");
}
SysUser user = null;
StudentUserMatchDTO student = null;
if (StrUtil.isNotBlank(row.getSchoolName()) && StrUtil.isNotBlank(row.getStudentNo())) {
user = extractUser(remoteUserService.getStudentBySchoolNameAndStudentNo(row.getSchoolName(), row.getStudentNo()));
if (user == null) {
student = studentByKey.get(studentKey);
if (student == null) {
rowErrors.add("学生不存在");
} else if (!ObjectUtil.equals(user.getName(), row.getStudentName())) {
} else if (!ObjectUtil.equals(student.getName(), row.getStudentName())) {
rowErrors.add("学生姓名与学校、学号不匹配");
}
}
if (rowErrors.isEmpty() && user != null) {
validatedRows.add(new ValidatedImportRow(row.getLineNum(), user.getUserId(), row.getStudentName(), row.getCardNumber()));
if (rowErrors.isEmpty() && student != null) {
validatedRows.add(new ValidatedImportRow(row.getLineNum(), student.getUserId(), row.getStudentName(), row.getCardNumber()));
} else {
appendErrors(errors, row.getLineNum(), rowErrors);
}
@@ -220,7 +238,7 @@ public class WalletActiveImportServiceImpl implements WalletActiveImportService
});
validateCardOccupancy(rows, errors);
if (!errors.isEmpty()) {
return R.failed(errors);
return R.failed(capErrors(errors));
}
AsyncTaskProgressVO progress = asyncTaskExecutorService.submit(redisKey, taskName, rows.size(), reporter -> {
for (ValidatedImportRow row : rows) {
@@ -344,13 +362,67 @@ public class WalletActiveImportServiceImpl implements WalletActiveImportService
}
/**
* 从 Feign 统一响应中安全提取用户,失败响应或空数据均按未找到处理。
* 按手机号去重分批调用远程批量查询,构建手机号到候选用户的映射。
*
* @param response UPMS 用户查询响应
* @return 用户实体;响应失败或数据为空时返回 {@code null}
* @param mobiles 当前 Excel 全部行的手机号(允许重复与空值)
* @return 手机号到候选用户列表的映射;查询失败的批次按未命中处理,由行级校验统一提示
*/
private SysUser extractUser(R<UserInfo> response) {
return response == null || !response.isOk() || response.getData() == null ? null : response.getData().getSysUser();
private Map<String, List<SysUser>> loadUsersByMobile(List<String> mobiles) {
List<String> distinctMobiles = mobiles.stream()
.filter(StrUtil::isNotBlank)
.distinct()
.toList();
Map<String, List<SysUser>> usersByMobile = new HashMap<>();
for (List<String> batch : ListUtil.partition(distinctMobiles, LOOKUP_BATCH_SIZE)) {
R<List<SysUser>> response = remoteUserService.getUsersByMobiles(batch);
if (response == null || !response.isOk() || response.getData() == null) {
continue;
}
response.getData().stream()
.filter(user -> ObjectUtil.isNotEmpty(user.getPhone()))
.forEach(user -> usersByMobile.computeIfAbsent(user.getPhone(), key -> new ArrayList<>()).add(user));
}
return usersByMobile;
}
/**
* 按学校和学号组合键去重分批调用远程批量查询,构建组合键到学生资料的映射。
*
* @param rows 学生 Excel 数据行
* @return 组合键到学生资料的映射;查询失败的批次按未命中处理
*/
private Map<String, StudentUserMatchDTO> loadStudentsByKey(List<WalletStudentImportActiveDto> rows) {
List<StudentSchoolNoDTO> keys = rows.stream()
.filter(row -> ObjectUtil.isAllNotEmpty(row.getSchoolName(), row.getStudentNo()))
.map(row -> new StudentSchoolNoDTO(row.getSchoolName(), row.getStudentNo()))
.distinct()
.toList();
Map<String, StudentUserMatchDTO> studentByKey = new HashMap<>();
for (List<StudentSchoolNoDTO> batch : ListUtil.partition(keys, LOOKUP_BATCH_SIZE)) {
R<List<StudentUserMatchDTO>> response = remoteUserService.getStudentsBySchoolNameAndStudentNoBatch(batch);
if (response == null || !response.isOk() || response.getData() == null) {
continue;
}
response.getData().forEach(match -> studentByKey.put(
buildStudentKey(match.getSchoolName(), match.getStudentNo()), match));
}
return studentByKey;
}
/**
* 截断超量的逐行错误,仅保留前 {@value #MAX_RETURNED_ERRORS} 条并追加一条汇总说明。
*
* @param errors 完整校验错误集合
* @return 不超过上限的错误列表;未超量时原样返回
*/
private List<ErrorMessage> capErrors(List<ErrorMessage> errors) {
if (errors.size() <= MAX_RETURNED_ERRORS) {
return errors;
}
List<ErrorMessage> capped = new ArrayList<>(errors.subList(0, MAX_RETURNED_ERRORS));
capped.add(new ErrorMessage(null, Set.of(
"错误共 " + errors.size() + " 条,仅展示前 " + MAX_RETURNED_ERRORS + " 条,请修正后重新导入")));
return capped;
}
/**
@@ -1,12 +1,18 @@
package com.bsd.pjcloud.wallet.service.impl.recharge;
import cn.hutool.core.collection.ListUtil;
import cn.hutool.core.lang.Validator;
import cn.hutool.core.util.ObjectUtil;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
import cn.hutool.core.util.StrUtil;
import com.bsd.pjcloud.admin.api.dto.StudentSchoolNoDTO;
import com.bsd.pjcloud.admin.api.dto.StudentUserMatchDTO;
import com.bsd.pjcloud.admin.api.entity.SysDictItem;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.feign.RemoteDictService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.excel.vo.ErrorMessage;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
@@ -24,22 +30,24 @@ import com.bsd.pjcloud.wallet.service.WalletRechargeInvoiceService;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.tuple.ImmutablePair;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.BindingResult;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 充值/扣款申请 Excel 导入服务实现。
* <p>负责默认用户模板校验、申请或发票创建及导入明细落库,不承载申请 CRUD 与审批逻辑。</p>
* <p>同步阶段完成整表解析、批量用户定位和业务校验并立即返回逐行错误;校验全部通过后提交公共异步事务任务,
* 后台创建申请与导入明细并逐批报告进度,避免大文件导入触发网关超时。</p>
*/
@Service
@RequiredArgsConstructor
@@ -48,6 +56,21 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply
/** 充值流水财务标签使用的字典类型,Excel 标签需转换为稳定字典值后落库。 */
private static final String TRANSACTION_TAG_DICT_TYPE = "wallet_transaction_tag";
/** 默认用户充值导入任务的 Redis 业务命名空间,充值、重置和发票充值导入共用,任务名区分场景。 */
private static final String DEFAULT_IMPORT_REDIS_KEY = "wallet:recharge:import:default";
/** 学生充值导入任务的 Redis 业务命名空间。 */
private static final String STUDENT_IMPORT_REDIS_KEY = "wallet:recharge:import:student";
/** 批量用户定位单次远程请求携带的最大键数量,防止 SQL 过长。 */
private static final int LOOKUP_BATCH_SIZE = 1000;
/** 异步落库阶段单批保存的明细行数,配合进度报告器逐批推进。 */
private static final int SAVE_BATCH_SIZE = 500;
/** 单次导入同步返回的最大错误条数,超出部分以汇总行提示,避免大文件全错时响应体过大。 */
private static final int MAX_RETURNED_ERRORS = 1000;
/** 充值/扣款申请 Mapper,用于创建不绑定发票的申请主记录。 */
private final WalletRechargeApplyMapper walletRechargeApplyMapper;
@@ -57,133 +80,217 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply
/** 充值发票服务,用于绑定发票导入时创建基础发票记录。 */
private final WalletRechargeInvoiceService walletRechargeInvoiceService;
/** 用户远程服务,用于按姓名和手机号精确定位默认用户。 */
/** 用户远程服务,用于批量按姓名和手机号定位默认用户。 */
private final RemoteUserService remoteUserService;
/** 字典远程服务,用于将 Excel 财务标签名称转换为持久化值。 */
private final RemoteDictService remoteDictService;
/** 公共异步任务执行器,用于后台事务落库并向前端报告逐批进度。 */
private final AsyncTaskExecutorService asyncTaskExecutorService;
/**
* 校验默认用户充值、扣款或退款模板,并创建申请及明细记录。
* 校验默认用户充值、扣款或退款模板,并提交异步导入任务。
*
* @param rows 默认用户 Excel 数据行
* @param bindingResult Excel 解析阶段的逐行校验结果
* @param bizType 充值业务类型协议值
* @return 校验失败时返回逐行错误,通过时返回导入成功结果
* @return 校验失败时返回逐行错误,通过时返回异步任务初始进度
*/
@Override
@Transactional(rollbackFor = Exception.class)
public R<?> importDefault(List<WalletRechargeExcelDto> rows, BindingResult bindingResult, String bizType) {
validateBizType(bizType, false);
ImmutablePair<List<ErrorMessage>, List<WalletRechargeExcelDto>> pair = validateExcelData(rows, bindingResult, true);
if (ObjectUtil.isNotEmpty(pair.getLeft())) {
return R.failed(pair.getLeft());
}
return createApplyAndDetails(pair.getRight(), bizType, false);
return submitApplyImport(pair.getRight(), bizType, false, "默认用户充值导入");
}
/**
* 校验默认用户余额重置模板,并以零金额创建申请明细。
* 校验默认用户余额重置模板,并以零金额提交异步导入任务。
*
* @param rows 默认用户余额重置 Excel 数据行
* @param bindingResult Excel 解析阶段的逐行校验结果
* @param bizType 余额重置业务类型协议值
* @return 校验失败时返回逐行错误,通过时返回导入成功结果
* @return 校验失败时返回逐行错误,通过时返回异步任务初始进度
*/
@Override
@Transactional(rollbackFor = Exception.class)
public R<?> importDefaultReset(List<WalletRechargeExcelDto> rows, BindingResult bindingResult, String bizType) {
validateBizType(bizType, true);
ImmutablePair<List<ErrorMessage>, List<WalletRechargeExcelDto>> pair = validateExcelData(rows, bindingResult, false);
if (ObjectUtil.isNotEmpty(pair.getLeft())) {
return R.failed(pair.getLeft());
}
return createApplyAndDetails(pair.getRight(), bizType, true);
return submitApplyImport(pair.getRight(), bizType, true, "默认用户余额重置导入");
}
/**
* 校验学生充值、扣款或退款模板,并创建申请及明细记录。
* 校验学生充值、扣款或退款模板,并提交异步导入任务。
*
* @param rows 学生 Excel 数据行
* @param bindingResult Excel 解析阶段的逐行校验结果
* @param bizType 充值业务类型协议值
* @return 校验失败时返回逐行错误,通过时返回导入成功结果
* @return 校验失败时返回逐行错误,通过时返回异步任务初始进度
*/
@Override
@Transactional(rollbackFor = Exception.class)
public R<?> importStudent(List<WalletStudentRechargeExcelDto> rows, BindingResult bindingResult, String bizType) {
validateBizType(bizType, false);
ImmutablePair<List<ErrorMessage>, List<WalletStudentRechargeExcelDto>> pair = validateStudentExcelData(rows, bindingResult, true);
if (ObjectUtil.isNotEmpty(pair.getLeft())) {
return R.failed(pair.getLeft());
}
return createStudentApplyAndDetails(pair.getRight(), bizType, false);
return submitStudentApplyImport(pair.getRight(), bizType, false, "学生充值导入");
}
/**
* 校验学生余额重置模板,并以零金额创建申请明细。
* 校验学生余额重置模板,并以零金额提交异步导入任务。
*
* @param rows 学生余额重置 Excel 数据行
* @param bindingResult Excel 解析阶段的逐行校验结果
* @param bizType 余额重置业务类型协议值
* @return 校验失败时返回逐行错误,通过时返回导入成功结果
* @return 校验失败时返回逐行错误,通过时返回异步任务初始进度
*/
@Override
@Transactional(rollbackFor = Exception.class)
public R<?> importStudentReset(List<WalletStudentRechargeExcelDto> rows, BindingResult bindingResult, String bizType) {
validateBizType(bizType, true);
ImmutablePair<List<ErrorMessage>, List<WalletStudentRechargeExcelDto>> pair = validateStudentExcelData(rows, bindingResult, false);
if (ObjectUtil.isNotEmpty(pair.getLeft())) {
return R.failed(pair.getLeft());
}
return createStudentApplyAndDetails(pair.getRight(), bizType, true);
return submitStudentApplyImport(pair.getRight(), bizType, true, "学生余额重置导入");
}
/**
* 校验绑定发票的默认用户充值模板,创建基础发票及未绑定的导入明细。
* 校验绑定发票的默认用户充值模板,并提交创建基础发票及导入明细的异步任务。
*
* @param rows 默认用户充值 Excel 数据行
* @param bindingResult Excel 解析阶段的逐行校验结果
* @return 校验失败时返回逐行错误,通过时返回导入成功结果
* @return 校验失败时返回逐行错误,通过时返回异步任务初始进度
*/
@Override
@Transactional(rollbackFor = Exception.class)
public R<?> importTopup(List<WalletRechargeExcelDto> rows, BindingResult bindingResult) {
ImmutablePair<List<ErrorMessage>, List<WalletRechargeExcelDto>> pair = validateExcelData(rows, bindingResult, true);
if (ObjectUtil.isNotEmpty(pair.getLeft())) {
return R.failed(pair.getLeft());
}
List<WalletRechargeExcelDto> validatedRows = pair.getRight();
PjcloudUser loginUser = SecurityUtils.getUser();
Integer totalPrice = validatedRows.stream().map(WalletRechargeExcelDto::getPriceNumber).reduce(Integer::sum).orElse(0);
AsyncTaskProgressVO progress = asyncTaskExecutorService.submit(DEFAULT_IMPORT_REDIS_KEY, "发票充值导入", validatedRows.size(),
reporter -> saveTopupDetails(validatedRows, totalPrice, reporter));
return R.ok(progress, "Excel校验通过,已开始后台导入");
}
/**
* 提交默认用户充值导入异步任务,任务内创建申请主记录并批量保存明细。
*
* @param rows 已完成用户定位与字段校验的数据行
* @param bizType 充值业务类型协议值
* @param resetBalance 是否为余额重置;为真时明细金额固定为零
* @param taskName 异步任务展示名称
* @return 异步任务初始进度响应
*/
private R<?> submitApplyImport(List<WalletRechargeExcelDto> rows, String bizType, boolean resetBalance, String taskName) {
AsyncTaskProgressVO progress = asyncTaskExecutorService.submit(DEFAULT_IMPORT_REDIS_KEY, taskName, rows.size(),
reporter -> saveApplyAndDetails(rows, bizType, resetBalance, reporter));
return R.ok(progress, "Excel校验通过,已开始后台导入");
}
/**
* 提交学生充值导入异步任务,任务内创建申请主记录并批量保存明细。
*
* @param rows 已完成学生定位与字段校验的数据行
* @param bizType 充值业务类型协议值
* @param resetBalance 是否为余额重置;为真时明细金额固定为零
* @param taskName 异步任务展示名称
* @return 异步任务初始进度响应
*/
private R<?> submitStudentApplyImport(List<WalletStudentRechargeExcelDto> rows, String bizType, boolean resetBalance, String taskName) {
AsyncTaskProgressVO progress = asyncTaskExecutorService.submit(STUDENT_IMPORT_REDIS_KEY, taskName, rows.size(),
reporter -> saveStudentApplyAndDetails(rows, bizType, resetBalance, reporter));
return R.ok(progress, "Excel校验通过,已开始后台导入");
}
/**
* 在异步事务内创建当前登录人的申请主记录,并批量保存导入明细。
*
* @param rows 已完成用户定位与字段校验的数据行
* @param bizType 充值业务类型协议值
* @param resetBalance 是否为余额重置;为真时明细金额固定为零
* @param reporter 异步任务进度报告器
*/
private void saveApplyAndDetails(List<WalletRechargeExcelDto> rows, String bizType, boolean resetBalance,
AsyncTaskProgressReporter reporter) {
Long applyId = insertApply(bizType);
saveDetailsInBatches(rows.stream()
.map(row -> WalletRechargeImportEntity.builder()
.applyId(applyId)
.price(resetBalance ? 0 : row.getPriceNumber())
.userId(row.getUserId())
.userDeptId(row.getUserDeptId())
.tags(row.getTags())
// Excel"备注"列随明细落库,审批通过后写入钱包流水 remark
.remark(row.getRemark())
.build())
.toList(), reporter);
}
/**
* 在异步事务内创建学生充值申请主记录,并批量保存导入明细。
*
* @param rows 已完成学生定位与字段校验的数据行
* @param bizType 充值业务类型协议值
* @param resetBalance 是否为余额重置;为真时明细金额固定为零
* @param reporter 异步任务进度报告器
*/
private void saveStudentApplyAndDetails(List<WalletStudentRechargeExcelDto> rows, String bizType, boolean resetBalance,
AsyncTaskProgressReporter reporter) {
Long applyId = insertApply(bizType);
saveDetailsInBatches(rows.stream()
.map(row -> WalletRechargeImportEntity.builder()
.applyId(applyId)
.price(resetBalance ? 0 : row.getPriceNumber())
.userId(row.getUserId())
.userDeptId(row.getUserDeptId())
.tags(row.getTags())
// Excel"备注"列随明细落库,审批通过后写入钱包流水 remark
.remark(row.getRemark())
.build())
.toList(), reporter);
}
/**
* 在异步事务内创建基础发票并保存未绑定申请的导入明细。
*
* @param rows 已完成用户定位与字段校验的数据行
* @param totalPrice 同步阶段汇总的发票总金额(分)
* @param reporter 异步任务进度报告器
*/
private void saveTopupDetails(List<WalletRechargeExcelDto> rows, Integer totalPrice, AsyncTaskProgressReporter reporter) {
PjcloudUser loginUser = SecurityUtils.getUser();
WalletRechargeInvoiceEntity invoice = walletRechargeInvoiceService.createBaseInvoice(loginUser, totalPrice);
if (invoice == null) {
throw new IllegalStateException("导入失败");
}
List<WalletRechargeImportEntity> details = validatedRows.stream()
saveDetailsInBatches(rows.stream()
.map(row -> WalletRechargeImportEntity.builder()
.price(row.getPriceNumber())
.userId(row.getUserId())
.userDeptId(row.getUserDeptId())
.invoiceId(invoice.getId())
.tags(row.getTags())
// Excel"备注"列随明细落库,审批通过后写入钱包流水 remark
.remark(row.getRemark())
.build())
.toList();
walletRechargeImportService.saveBatch(details);
return R.ok(null, "导入成功");
.toList(), reporter);
}
/**
* 创建当前登录人的申请主记录,并批量保存已校验导入明细。
* 以当前登录人身份创建充值申请主记录,供异步任务内明细挂靠。
*
* @param rows 已完成用户定位与字段校验的数据行
* @param bizType 充值业务类型协议值
* @param resetBalance 是否为余额重置;为真时明细金额固定为零
* @return 导入成功结果
* @return 新建申请主键
*/
private R<?> createApplyAndDetails(List<WalletRechargeExcelDto> rows, String bizType, boolean resetBalance) {
private Long insertApply(String bizType) {
PjcloudUser loginUser = SecurityUtils.getUser();
WalletRechargeApplyEntity apply = new WalletRechargeApplyEntity();
apply.setDeptId(loginUser.getDeptId());
@@ -192,51 +299,22 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply
if (walletRechargeApplyMapper.insert(apply) <= 0) {
throw new IllegalStateException("导入失败");
}
List<WalletRechargeImportEntity> details = rows.stream()
.map(row -> WalletRechargeImportEntity.builder()
.applyId(apply.getId())
.price(resetBalance ? 0 : row.getPriceNumber())
.userId(row.getUserId())
.userDeptId(row.getUserDeptId())
.tags(row.getTags())
// Excel"备注"列随明细落库,审批通过后写入钱包流水 remark
.remark(row.getRemark())
.build())
.toList();
walletRechargeImportService.saveBatch(details);
return R.ok(null, "导入成功");
return apply.getId();
}
/**
* 创建学生充值/扣款申请主记录,并批量保存已解析学生用户的明细。
* 分批保存导入明细并逐批报告进度;整体处于异步任务的同一事务内,任一批失败全部回滚。
*
* @param rows 已完成学生定位与字段校验的数据行
* @param bizType 充值业务类型协议值
* @param resetBalance 是否为余额重置;为真时明细金额固定为零
* @return 导入成功结果
* @param details 已组装完毕的导入明细
* @param reporter 异步任务进度报告器
*/
private R<?> createStudentApplyAndDetails(List<WalletStudentRechargeExcelDto> rows, String bizType, boolean resetBalance) {
PjcloudUser loginUser = SecurityUtils.getUser();
WalletRechargeApplyEntity apply = new WalletRechargeApplyEntity();
apply.setDeptId(loginUser.getDeptId());
apply.setUserId(loginUser.getId());
apply.setBizType(bizType);
if (walletRechargeApplyMapper.insert(apply) <= 0) {
throw new IllegalStateException("导入失败");
private void saveDetailsInBatches(List<WalletRechargeImportEntity> details, AsyncTaskProgressReporter reporter) {
int processed = 0;
for (List<WalletRechargeImportEntity> batch : ListUtil.partition(details, SAVE_BATCH_SIZE)) {
walletRechargeImportService.saveBatch(batch, SAVE_BATCH_SIZE);
processed += batch.size();
reporter.advance("已导入 " + processed + "/" + details.size() + " 行");
}
List<WalletRechargeImportEntity> details = rows.stream()
.map(row -> WalletRechargeImportEntity.builder()
.applyId(apply.getId())
.price(resetBalance ? 0 : row.getPriceNumber())
.userId(row.getUserId())
.userDeptId(row.getUserDeptId())
.tags(row.getTags())
// Excel"备注"列随明细落库,审批通过后写入钱包流水 remark
.remark(row.getRemark())
.build())
.toList();
walletRechargeImportService.saveBatch(details);
return R.ok(null, "导入成功");
}
/**
@@ -261,7 +339,7 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply
}
/**
* 校验默认用户模板并补齐用户、部门、分值金额及财务标签字典值。
* 校验默认用户模板并批量补齐用户、部门、分值金额及财务标签字典值。
*
* @param rows Excel 原始数据行
* @param bindingResult 公共 Excel 监听器生成的逐行错误
@@ -271,35 +349,36 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply
@SuppressWarnings("unchecked")
private ImmutablePair<List<ErrorMessage>, List<WalletRechargeExcelDto>> validateExcelData(
List<WalletRechargeExcelDto> rows, BindingResult bindingResult, boolean validatePrice) {
if (ObjectUtil.isEmpty(rows)) {
throw new IllegalArgumentException("读取excel数据错误");
}
List<WalletRechargeExcelDto> effectiveRows = rows.stream()
List<ErrorMessage> errors = getErrorMessageList(bindingResult);
List<WalletRechargeExcelDto> effectiveRows = rows == null ? List.of() : rows.stream()
.filter(row -> !ObjectUtil.isAllEmpty(row.getUsername(), row.getUserMobile(), row.getPrice()))
.toList();
if (effectiveRows.isEmpty()) {
throw new IllegalArgumentException("读取excel数据错误");
// 无有效行时优先回显解析期逐行错误(如字典未配置),确实为空表时给出明确提示
return ImmutablePair.of(errors.isEmpty() ? List.of(new ErrorMessage(1L, Set.of("Excel内容为空"))) : capErrors(errors), null);
}
List<ErrorMessage> errors = getErrorMessageList(bindingResult);
resolveTransactionTagValues(effectiveRows, errors);
List<String> userPhones = effectiveRows.stream().map(WalletRechargeExcelDto::getUserMobile).toList();
// 预统计手机号出现次数,替代逐行全表扫描的重复校验
Map<String, Long> phoneCounts = countValues(effectiveRows.stream().map(WalletRechargeExcelDto::getUserMobile).toList());
// 一次性批量定位全部用户,避免逐行 Feign 导致大文件同步校验超时
Map<String, List<SysUser>> usersByMobile = loadUsersByMobile(
effectiveRows.stream().map(WalletRechargeExcelDto::getUserMobile).toList());
for (WalletRechargeExcelDto row : effectiveRows) {
Set<String> rowErrors = new LinkedHashSet<>();
validatePrice(row, validatePrice, rowErrors);
resolveDefaultUser(row, rowErrors);
long phoneCount = userPhones.stream().filter(phone -> ObjectUtil.equals(phone, row.getUserMobile())).count();
if (ObjectUtil.isNotEmpty(row.getUserMobile()) && phoneCount > 1) {
resolveDefaultUser(row, usersByMobile, rowErrors);
if (ObjectUtil.isNotEmpty(row.getUserMobile()) && phoneCounts.getOrDefault(row.getUserMobile(), 0L) > 1) {
rowErrors.add("用户手机号 <" + row.getUserMobile() + "> 重复");
}
if (!rowErrors.isEmpty()) {
errors.add(new ErrorMessage(row.getLineNum(), rowErrors));
}
}
return errors.isEmpty() ? ImmutablePair.of(null, effectiveRows) : ImmutablePair.of(errors, null);
return errors.isEmpty() ? ImmutablePair.of(null, effectiveRows) : ImmutablePair.of(capErrors(errors), null);
}
/**
* 校验学生模板并按学校、学号补齐用户及部门信息。
* 校验学生模板并按学校、学号批量补齐学生用户及部门信息。
*
* @param rows 学生 Excel 原始数据行
* @param bindingResult 公共 Excel 监听器生成的逐行错误
@@ -309,34 +388,79 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply
@SuppressWarnings("unchecked")
private ImmutablePair<List<ErrorMessage>, List<WalletStudentRechargeExcelDto>> validateStudentExcelData(
List<WalletStudentRechargeExcelDto> rows, BindingResult bindingResult, boolean validatePrice) {
if (ObjectUtil.isEmpty(rows)) {
throw new IllegalArgumentException("读取excel数据错误");
}
List<WalletStudentRechargeExcelDto> effectiveRows = rows.stream()
List<ErrorMessage> errors = getErrorMessageList(bindingResult);
List<WalletStudentRechargeExcelDto> effectiveRows = rows == null ? List.of() : rows.stream()
.filter(row -> !ObjectUtil.isAllEmpty(row.getStudentName(), row.getSchoolName(), row.getStudentNo(), row.getPrice()))
.toList();
if (effectiveRows.isEmpty()) {
throw new IllegalArgumentException("读取excel数据错误");
return ImmutablePair.of(errors.isEmpty() ? List.of(new ErrorMessage(1L, Set.of("Excel内容为空"))) : capErrors(errors), null);
}
List<ErrorMessage> errors = getErrorMessageList(bindingResult);
resolveStudentTransactionTagValues(effectiveRows, errors);
List<String> studentKeys = effectiveRows.stream()
.map(row -> buildStudentKey(row.getSchoolName(), row.getStudentNo()))
.toList();
Map<String, Long> studentCounts = countValues(effectiveRows.stream()
.map(row -> buildStudentKey(row.getSchoolName(), row.getStudentNo())).toList());
Map<String, StudentUserMatchDTO> studentByKey = loadStudentsByKey(effectiveRows);
for (WalletStudentRechargeExcelDto row : effectiveRows) {
Set<String> rowErrors = new LinkedHashSet<>();
validateStudentPrice(row, validatePrice, rowErrors);
resolveStudentUser(row, rowErrors);
resolveStudentUser(row, studentByKey, rowErrors);
String studentKey = buildStudentKey(row.getSchoolName(), row.getStudentNo());
long studentCount = studentKeys.stream().filter(key -> ObjectUtil.equals(key, studentKey)).count();
if (ObjectUtil.isAllNotEmpty(row.getSchoolName(), row.getStudentNo()) && studentCount > 1) {
if (ObjectUtil.isAllNotEmpty(row.getSchoolName(), row.getStudentNo())
&& studentCounts.getOrDefault(studentKey, 0L) > 1) {
rowErrors.add("学校和学号 <" + row.getSchoolName() + " / " + row.getStudentNo() + "> 在Excel中重复");
}
if (!rowErrors.isEmpty()) {
errors.add(new ErrorMessage(row.getLineNum(), rowErrors));
}
}
return errors.isEmpty() ? ImmutablePair.of(null, effectiveRows) : ImmutablePair.of(errors, null);
return errors.isEmpty() ? ImmutablePair.of(null, effectiveRows) : ImmutablePair.of(capErrors(errors), null);
}
/**
* 按手机号去重分批调用远程批量查询,构建手机号到候选用户的映射。
*
* @param mobiles 当前 Excel 全部行的手机号(允许重复与空值)
* @return 手机号到候选用户列表的映射;查询失败的批次按未命中处理,由行级校验统一提示
*/
private Map<String, List<SysUser>> loadUsersByMobile(List<String> mobiles) {
List<String> distinctMobiles = mobiles.stream()
.filter(Validator::isMobile)
.distinct()
.toList();
Map<String, List<SysUser>> usersByMobile = new HashMap<>();
for (List<String> batch : ListUtil.partition(distinctMobiles, LOOKUP_BATCH_SIZE)) {
R<List<SysUser>> response = remoteUserService.getUsersByMobiles(batch);
if (response == null || !response.isOk() || response.getData() == null) {
continue;
}
response.getData().stream()
.filter(user -> ObjectUtil.isNotEmpty(user.getPhone()))
.forEach(user -> usersByMobile.computeIfAbsent(user.getPhone(), key -> new ArrayList<>()).add(user));
}
return usersByMobile;
}
/**
* 按学校和学号组合键去重分批调用远程批量查询,构建组合键到学生资料的映射。
*
* @param rows 学生 Excel 数据行
* @return 去除首尾空白后的组合键到学生资料的映射;查询失败的批次按未命中处理
*/
private Map<String, StudentUserMatchDTO> loadStudentsByKey(List<WalletStudentRechargeExcelDto> rows) {
List<StudentSchoolNoDTO> keys = rows.stream()
.filter(row -> ObjectUtil.isAllNotEmpty(row.getSchoolName(), row.getStudentNo()))
.map(row -> new StudentSchoolNoDTO(StrUtil.trim(row.getSchoolName()), StrUtil.trim(row.getStudentNo())))
.distinct()
.toList();
Map<String, StudentUserMatchDTO> studentByKey = new HashMap<>();
for (List<StudentSchoolNoDTO> batch : ListUtil.partition(keys, LOOKUP_BATCH_SIZE)) {
R<List<StudentUserMatchDTO>> response = remoteUserService.getStudentsBySchoolNameAndStudentNoBatch(batch);
if (response == null || !response.isOk() || response.getData() == null) {
continue;
}
response.getData().forEach(match -> studentByKey.put(
buildStudentKey(match.getSchoolName(), match.getStudentNo()), match));
}
return studentByKey;
}
/**
@@ -384,12 +508,13 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply
}
/**
* 按姓名和手机号精确定位默认用户,并把用户及部门 ID 写回导入行。
* 按姓名和手机号从批量查询结果中精确定位默认用户,并把用户及部门 ID 写回导入行。
*
* @param row 当前 Excel 数据行
* @param usersByMobile 批量查询得到的手机号到候选用户映射
* @param errors 当前行错误集合
*/
private void resolveDefaultUser(WalletRechargeExcelDto row, Set<String> errors) {
private void resolveDefaultUser(WalletRechargeExcelDto row, Map<String, List<SysUser>> usersByMobile, Set<String> errors) {
if (ObjectUtil.isEmpty(row.getUsername())) {
errors.add("请输入用户名");
return;
@@ -402,23 +527,28 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply
errors.add("用户手机号格式不正确");
return;
}
R<UserInfo> response = remoteUserService.getUserByNameAndMobile(row.getUsername(), row.getUserMobile());
Optional<SysUser> user = Optional.ofNullable(response).map(R::getData).map(UserInfo::getSysUser);
if (user.isEmpty() || !ObjectUtil.equals(user.get().getName(), row.getUsername())) {
List<SysUser> candidates = usersByMobile.get(StrUtil.trim(row.getUserMobile()));
SysUser matched = candidates == null ? null : candidates.stream()
.filter(user -> ObjectUtil.equals(user.getName(), row.getUsername()))
.findFirst()
.orElse(null);
if (matched == null) {
errors.add("用户不存在");
return;
}
row.setUserId(user.get().getUserId());
row.setUserDeptId(user.get().getDeptId());
row.setUserId(matched.getUserId());
row.setUserDeptId(matched.getDeptId());
}
/**
* 按学校和学号精确定位学生,并校验模板姓名与系统用户一致。
* 按学校和学号从批量查询结果中精确定位学生,并校验模板姓名与系统用户一致。
*
* @param row 当前学生 Excel 数据行
* @param studentByKey 批量查询得到的组合键到学生资料映射
* @param errors 当前行错误集合
*/
private void resolveStudentUser(WalletStudentRechargeExcelDto row, Set<String> errors) {
private void resolveStudentUser(WalletStudentRechargeExcelDto row, Map<String, StudentUserMatchDTO> studentByKey,
Set<String> errors) {
if (ObjectUtil.isEmpty(row.getStudentName())) {
errors.add("请输入学生姓名");
}
@@ -431,22 +561,22 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply
if (ObjectUtil.hasEmpty(row.getSchoolName(), row.getStudentNo())) {
return;
}
R<UserInfo> response = remoteUserService.getStudentBySchoolNameAndStudentNo(row.getSchoolName(), row.getStudentNo());
Optional<SysUser> user = Optional.ofNullable(response).map(R::getData).map(UserInfo::getSysUser);
if (user.isEmpty()) {
StudentUserMatchDTO student = studentByKey.get(
buildStudentKey(StrUtil.trim(row.getSchoolName()), StrUtil.trim(row.getStudentNo())));
if (student == null) {
errors.add("学生不存在");
return;
}
if (!ObjectUtil.equals(user.get().getName(), row.getStudentName())) {
if (!ObjectUtil.equals(student.getName(), row.getStudentName())) {
errors.add("学生姓名与学校、学号不匹配");
return;
}
row.setUserId(user.get().getUserId());
row.setUserDeptId(user.get().getDeptId());
row.setUserId(student.getUserId());
row.setUserDeptId(student.getDeptId());
}
/**
* 生成学校和学号组合键,仅用于当前学生 Excel 内重复校验。
* 生成学校和学号组合键,仅用于当前学生 Excel 内重复校验与批量查询映射。
*
* @param schoolName 学校名称
* @param studentNo 学号
@@ -456,6 +586,34 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply
return Objects.toString(schoolName, "") + "\u0000" + Objects.toString(studentNo, "");
}
/**
* 统计非空文本在 Excel 中的出现次数,用于重复项校验。
*
* @param values 待统计文本列表
* @return 文本到出现次数的映射
*/
private Map<String, Long> countValues(List<String> values) {
return values.stream()
.filter(StrUtil::isNotBlank)
.collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
}
/**
* 截断超量的逐行错误,仅保留前 {@value #MAX_RETURNED_ERRORS} 条并追加一条汇总说明。
*
* @param errors 完整校验错误集合
* @return 不超过上限的错误列表;未超量时原样返回
*/
private List<ErrorMessage> capErrors(List<ErrorMessage> errors) {
if (errors.size() <= MAX_RETURNED_ERRORS) {
return errors;
}
List<ErrorMessage> capped = new ArrayList<>(errors.subList(0, MAX_RETURNED_ERRORS));
capped.add(new ErrorMessage(null, Set.of(
"错误共 " + errors.size() + " 条,仅展示前 " + MAX_RETURNED_ERRORS + " 条,请修正后重新导入")));
return capped;
}
/**
* 获取公共 Excel 解析器写入 BindingResult 的逐行错误集合。
*
@@ -465,7 +623,7 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply
@SuppressWarnings("unchecked")
private List<ErrorMessage> getErrorMessageList(BindingResult bindingResult) {
Object target = bindingResult == null ? null : bindingResult.getTarget();
return target instanceof List<?> ? (List<ErrorMessage>) target : new java.util.ArrayList<>();
return target instanceof List<?> ? (List<ErrorMessage>) target : new ArrayList<>();
}
/**
@@ -15,4 +15,17 @@ spring:
config:
import:
- optional:nacos:application-@profiles.active@.yml
- optional:nacos:${spring.application.name}-@profiles.active@.yml
- optional:nacos:${spring.application.name}-@profiles.active@.yml
datasource:
druid:
# 远程库网络会掐断空闲约 10 秒的连接:借出前强制校验,杜绝拿到已被服务端断开的死连接。
test-on-borrow: true
test-while-idle: true
validation-query: SELECT 1
keep-alive: true
initial-size: 1
min-idle: 1
max-active: 16
time-between-eviction-runs-millis: 10000
min-evictable-idle-time-millis: 30000
keep-alive-between-time-runs-millis: 12000
@@ -0,0 +1,399 @@
package com.bsd.pjcloud.wallet.service.impl.recharge;
import com.bsd.pjcloud.admin.api.dto.StudentSchoolNoDTO;
import com.bsd.pjcloud.admin.api.dto.StudentUserMatchDTO;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.feign.RemoteDictService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskAction;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.excel.vo.ErrorMessage;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.wallet.api.dto.recharge.excel.WalletRechargeExcelDto;
import com.bsd.pjcloud.wallet.api.dto.recharge.excel.WalletStudentRechargeExcelDto;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeApplyEntity;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeImportEntity;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeInvoiceEntity;
import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargeApplyMapper;
import com.bsd.pjcloud.wallet.service.WalletRechargeImportService;
import com.bsd.pjcloud.wallet.service.WalletRechargeInvoiceService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.validation.BindingResult;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 充值导入服务同步校验与异步任务提交测试。
* <p>覆盖批量用户定位、逐行错误回显、空表显式提示和异步动作事务内落库行为。</p>
*/
@ExtendWith(MockitoExtension.class)
class WalletRechargeApplyImportServiceImplTest {
/** 充值申请 Mapper Mock。 */
@Mock
private WalletRechargeApplyMapper walletRechargeApplyMapper;
/** 充值导入明细服务 Mock。 */
@Mock
private WalletRechargeImportService walletRechargeImportService;
/** 充值发票服务 Mock。 */
@Mock
private WalletRechargeInvoiceService walletRechargeInvoiceService;
/** 用户远程服务 Mock。 */
@Mock
private RemoteUserService remoteUserService;
/** 字典远程服务 Mock。 */
@Mock
private RemoteDictService remoteDictService;
/** 公共异步任务执行器 Mock。 */
@Mock
private AsyncTaskExecutorService asyncTaskExecutorService;
/** 异步动作保存明细的参数捕获器。 */
@Captor
private ArgumentCaptor<List<WalletRechargeImportEntity>> detailsCaptor;
/** 被测充值导入服务。 */
@InjectMocks
private WalletRechargeApplyImportServiceImpl service;
/**
* 清理用例写入的安全上下文,避免登录人状态串扰后续用例。
*/
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
/** 桩执行器捕获到的异步业务动作,由用例在校验通过后手动驱动。 */
private AsyncTaskAction submittedAction;
/**
* 构造一个姓名、手机号齐全的默认用户导入行。
*
* @param lineNum Excel 行号
* @param name 用户姓名
* @param mobile 用户手机号
* @return 未做用户定位的导入行
*/
private WalletRechargeExcelDto buildDefaultRow(long lineNum, String name, String mobile) {
WalletRechargeExcelDto row = new WalletRechargeExcelDto();
row.setLineNum(lineNum);
row.setUsername(name);
row.setUserMobile(mobile);
row.setPrice("100");
return row;
}
/**
* 构造一个已定位的系统用户。
*
* @param userId 用户 ID
* @param deptId 部门 ID
* @param name 姓名
* @param phone 手机号
* @return 系统用户实体
*/
private SysUser buildSysUser(Long userId, Long deptId, String name, String phone) {
SysUser user = new SysUser();
user.setUserId(userId);
user.setDeptId(deptId);
user.setName(name);
user.setPhone(phone);
return user;
}
/**
* 构造一个不携带解析错误的 BindingResult。
*
* @return 目标为空错误列表的 BindingResult
*/
private BindingResult emptyBindingResult() {
BindingResult bindingResult = mock(BindingResult.class);
when(bindingResult.getTarget()).thenReturn(new ArrayList<>());
return bindingResult;
}
/**
* 构造固定登录人并写入安全上下文,供异步动作内创建申请或发票使用。
*/
private void loginAsImporter() {
// User 父类要求用户名和密码非空,占位密码仅满足构造校验
PjcloudUser loginUser = new PjcloudUser(9L, "admin", 8L, null, null, null, null, null, null, "N/A",
true, true, null, true, true, List.of());
SecurityContextHolder.getContext()
.setAuthentication(new UsernamePasswordAuthenticationToken(loginUser, "N/A"));
}
/**
* 桩化异步执行器:记录提交的业务动作但不执行,供用例手动驱动并断言。
*
* @param redisKey 期望的业务命名空间
* @param taskName 期望的任务名
* @param total 期望的总数
*/
private void stubSubmitCapturingAction(String redisKey, String taskName, int total) {
when(asyncTaskExecutorService.submit(eq(redisKey), eq(taskName), eq(total), any()))
.thenAnswer(invocation -> {
submittedAction = invocation.getArgument(3, AsyncTaskAction.class);
return new AsyncTaskProgressVO();
});
}
/**
* 有效行校验通过后应立即返回异步任务进度,且同步阶段不落库。
*/
@Test
void importDefaultValidRowsSubmitsAsyncTask() {
WalletRechargeExcelDto row = buildDefaultRow(2L, "张三", "13800000000");
when(remoteUserService.getUsersByMobiles(List.of("13800000000")))
.thenReturn(R.ok(List.of(buildSysUser(1L, 2L, "张三", "13800000000"))));
AsyncTaskProgressVO progress = new AsyncTaskProgressVO();
when(asyncTaskExecutorService.submit(eq("wallet:recharge:import:default"), eq("默认用户充值导入"), eq(1), any()))
.thenReturn(progress);
R<?> response = service.importDefault(List.of(row), emptyBindingResult(), "1");
assertTrue(response.isOk());
assertEquals(progress, response.getData());
verify(walletRechargeApplyMapper, never()).insert(any(WalletRechargeApplyEntity.class));
verify(asyncTaskExecutorService).submit(eq("wallet:recharge:import:default"), eq("默认用户充值导入"), eq(1), any());
}
/**
* 异步动作应在同一事务内创建申请并按批保存明细,逐批报告进度。
*/
@Test
void importDefaultAsyncActionSavesApplyAndDetails() {
WalletRechargeExcelDto row = buildDefaultRow(2L, "张三", "13800000000");
when(remoteUserService.getUsersByMobiles(List.of("13800000000")))
.thenReturn(R.ok(List.of(buildSysUser(1L, 2L, "张三", "13800000000"))));
stubSubmitCapturingAction("wallet:recharge:import:default", "默认用户充值导入", 1);
service.importDefault(List.of(row), emptyBindingResult(), "1");
assertNotNull(submittedAction);
loginAsImporter();
when(walletRechargeApplyMapper.insert(any(WalletRechargeApplyEntity.class))).thenReturn(1);
AsyncTaskProgressReporter reporter = mock(AsyncTaskProgressReporter.class);
submittedAction.execute(reporter);
verify(walletRechargeImportService).saveBatch(detailsCaptor.capture(), eq(500));
List<WalletRechargeImportEntity> details = detailsCaptor.getValue();
assertEquals(1, details.size());
assertEquals(10000, details.get(0).getPrice());
assertEquals(1L, details.get(0).getUserId());
assertEquals(2L, details.get(0).getUserDeptId());
verify(reporter).advance("已导入 1/1 行");
}
/**
* 批量定位未命中时应同步返回行级错误且不创建异步任务。
*/
@Test
void importDefaultUnknownUserReturnsRowErrors() {
WalletRechargeExcelDto row = buildDefaultRow(2L, "张三", "13800000000");
when(remoteUserService.getUsersByMobiles(List.of("13800000000"))).thenReturn(R.ok(List.of()));
R<?> response = service.importDefault(List.of(row), emptyBindingResult(), "1");
assertFalse(response.isOk());
List<ErrorMessage> errors = assertInstanceOf(List.class, response.getData());
assertTrue(errors.stream().anyMatch(error -> error.getErrors().contains("用户不存在")));
verify(asyncTaskExecutorService, never()).submit(any(), any(), anyInt(), any());
}
/**
* 批量查询远程失败时应按未命中处理,同样返回行级错误。
*/
@Test
void importDefaultLookupFailureReturnsRowErrors() {
WalletRechargeExcelDto row = buildDefaultRow(2L, "张三", "13800000000");
when(remoteUserService.getUsersByMobiles(List.of("13800000000"))).thenReturn(R.failed("服务不可用"));
R<?> response = service.importDefault(List.of(row), emptyBindingResult(), "1");
assertFalse(response.isOk());
List<ErrorMessage> errors = assertInstanceOf(List.class, response.getData());
assertTrue(errors.stream().anyMatch(error -> error.getErrors().contains("用户不存在")));
verify(asyncTaskExecutorService, never()).submit(any(), any(), anyInt(), any());
}
/**
* Excel 内同一手机号重复出现时应在两行同时提示重复。
*/
@Test
void importDefaultDuplicateMobileReturnsRowErrors() {
when(remoteUserService.getUsersByMobiles(List.of("13800000000")))
.thenReturn(R.ok(List.of(buildSysUser(1L, 2L, "张三", "13800000000"))));
R<?> response = service.importDefault(
List.of(buildDefaultRow(2L, "张三", "13800000000"), buildDefaultRow(3L, "张三", "13800000000")),
emptyBindingResult(), "1");
assertFalse(response.isOk());
List<ErrorMessage> errors = assertInstanceOf(List.class, response.getData());
assertEquals(2, errors.size());
assertTrue(errors.stream().allMatch(error -> error.getErrors().contains("用户手机号 <13800000000> 重复")));
}
/**
* 空文件应返回明确的“Excel内容为空”提示,而不是抛出通用读取异常。
*/
@Test
void importDefaultEmptyRowsReturnsExplicitError() {
R<?> response = service.importDefault(List.of(), emptyBindingResult(), "1");
assertFalse(response.isOk());
List<ErrorMessage> errors = assertInstanceOf(List.class, response.getData());
assertTrue(errors.get(0).getErrors().contains("Excel内容为空"));
}
/**
* 全部行都有错误时应只返回前 1000 条错误,并追加一条汇总说明行。
*/
@Test
void importDefaultCapsReturnedErrorsAtThousand() {
List<WalletRechargeExcelDto> rows = new ArrayList<>();
for (int i = 0; i < 1005; i++) {
rows.add(buildDefaultRow(2L + i, "张三", String.format("1380000%04d", i)));
}
when(remoteUserService.getUsersByMobiles(anyList())).thenReturn(R.ok(List.of()));
R<?> response = service.importDefault(rows, emptyBindingResult(), "1");
assertFalse(response.isOk());
List<ErrorMessage> errors = assertInstanceOf(List.class, response.getData());
assertEquals(1001, errors.size());
assertTrue(errors.get(1000).getErrors().toString().contains("仅展示前 1000 条"));
}
/**
* 全部行被解析监听器拒绝时应回显监听器错误,而不是覆盖为通用提示。
*/
@Test
void importDefaultListenerErrorsAreReturnedVerbatim() {
BindingResult bindingResult = mock(BindingResult.class);
List<ErrorMessage> listenerErrors = new ArrayList<>();
listenerErrors.add(new ErrorMessage(2L, Set.of("操作标签字典查询失败")));
when(bindingResult.getTarget()).thenReturn(listenerErrors);
R<?> response = service.importDefault(List.of(), bindingResult, "1");
assertFalse(response.isOk());
assertEquals(listenerErrors, response.getData());
}
/**
* 学生导入应按学校学号批量定位并使用学生任务命名空间。
*/
@Test
void importStudentValidRowsSubmitsAsyncTask() {
WalletStudentRechargeExcelDto row = new WalletStudentRechargeExcelDto();
row.setLineNum(2L);
row.setStudentName("李四");
row.setSchoolName("第一中学");
row.setStudentNo("S001");
row.setPrice("50");
StudentUserMatchDTO match = new StudentUserMatchDTO();
match.setSchoolName("第一中学");
match.setStudentNo("S001");
match.setUserId(11L);
match.setDeptId(12L);
match.setName("李四");
when(remoteUserService.getStudentsBySchoolNameAndStudentNoBatch(List.of(new StudentSchoolNoDTO("第一中学", "S001"))))
.thenReturn(R.ok(List.of(match)));
when(asyncTaskExecutorService.submit(eq("wallet:recharge:import:student"), eq("学生充值导入"), eq(1), any()))
.thenReturn(new AsyncTaskProgressVO());
R<?> response = service.importStudent(List.of(row), emptyBindingResult(), "1");
assertTrue(response.isOk());
verify(asyncTaskExecutorService).submit(eq("wallet:recharge:import:student"), eq("学生充值导入"), eq(1), any());
}
/**
* 学生姓名与学校学号不匹配时应返回行级错误且不创建异步任务。
*/
@Test
void importStudentNameMismatchReturnsRowErrors() {
WalletStudentRechargeExcelDto row = new WalletStudentRechargeExcelDto();
row.setLineNum(2L);
row.setStudentName("王五");
row.setSchoolName("第一中学");
row.setStudentNo("S001");
row.setPrice("50");
StudentUserMatchDTO match = new StudentUserMatchDTO();
match.setSchoolName("第一中学");
match.setStudentNo("S001");
match.setUserId(11L);
match.setName("李四");
when(remoteUserService.getStudentsBySchoolNameAndStudentNoBatch(List.of(new StudentSchoolNoDTO("第一中学", "S001"))))
.thenReturn(R.ok(List.of(match)));
R<?> response = service.importStudent(List.of(row), emptyBindingResult(), "1");
assertFalse(response.isOk());
List<ErrorMessage> errors = assertInstanceOf(List.class, response.getData());
assertTrue(errors.stream().anyMatch(error -> error.getErrors().contains("学生姓名与学校、学号不匹配")));
verify(asyncTaskExecutorService, never()).submit(any(), any(), anyInt(), any());
}
/**
* 发票充值导入的异步动作应在事务内创建发票并保存未绑定申请的明细。
*/
@Test
void importTopupAsyncActionCreatesInvoiceAndDetails() {
WalletRechargeExcelDto row = buildDefaultRow(2L, "张三", "13800000000");
when(remoteUserService.getUsersByMobiles(List.of("13800000000")))
.thenReturn(R.ok(List.of(buildSysUser(1L, 2L, "张三", "13800000000"))));
stubSubmitCapturingAction("wallet:recharge:import:default", "发票充值导入", 1);
service.importTopup(List.of(row), emptyBindingResult());
assertNotNull(submittedAction);
loginAsImporter();
WalletRechargeInvoiceEntity invoice = new WalletRechargeInvoiceEntity();
invoice.setId(7L);
when(walletRechargeInvoiceService.createBaseInvoice(any(), eq(10000))).thenReturn(invoice);
submittedAction.execute(mock(AsyncTaskProgressReporter.class));
verify(walletRechargeImportService).saveBatch(detailsCaptor.capture(), eq(500));
List<WalletRechargeImportEntity> details = detailsCaptor.getValue();
assertEquals(1, details.size());
assertEquals(7L, details.get(0).getInvoiceId());
assertEquals(10000, details.get(0).getPrice());
assertEquals(1L, details.get(0).getUserId());
}
}