From 53c1ac7cb2d9d905e4be731419a42b336fb1e9da Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Mon, 14 Sep 2026 22:39:36 +0800 Subject: [PATCH 01/12] =?UTF-8?q?fix:=E8=A7=A3=E5=86=B3=E9=80=80=E6=AC=BE?= =?UTF-8?q?=E6=8A=A5=E9=94=99[400=20Bad=20Request]=20during=20[POST]=20to?= =?UTF-8?q?=20[http://pjcloud-canteen-biz/canteenSpu/inner/updateSkuStockI?= =?UTF-8?q?dempotent]=20[RemoteCanteenSpuService#updateSkuStockIdempotent(?= =?UTF-8?q?CanteenSpuUpdateStockReqDTO)]:=20[{"code":1,"msg":"=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E5=B7=B2=E5=8F=98=E5=8C=96=EF=BC=8C=E8=AF=B7=E9=87=8D?= =?UTF-8?q?=E6=96=B0=E6=93=8D=E4=BD=9C","data":null,"ok":false}]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/CanteenSpuServiceImpl.java | 37 ++++++++--- .../impl/CanteenSpuServiceImplTest.java | 64 ++++++++++++++++++- 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java index e10bb2ede..6e166fc78 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java @@ -164,14 +164,15 @@ public class CanteenSpuServiceImpl extends ServiceImpl 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 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, ""); } } } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java b/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java index efc9b963d..6ce609ed2 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java @@ -5,9 +5,13 @@ import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity; 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; @@ -15,9 +19,16 @@ import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.Duration; 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; @@ -55,10 +66,13 @@ class CanteenSpuServiceImplTest { @AfterEach void tearDown() { TransactionSynchronizationManager.clearSynchronization(); + // 撤销最小 MessageSource 注入,避免静态 SpringContextHolder 影响后续测试类。 + ReflectionTestUtils.setField(SpringContextHolder.class, "beanFactory", null); } /** * 已确认应用后完成补偿的售后库存允许再次 APPLY;否则钱包成功后的任务重试会丢失库存恢复。 + * 正增量恢复走无条件原子累加,不再与快照做 CAS 对比。 */ @Test void compensatedRefundInventoryShouldAllowRetry() { @@ -66,15 +80,48 @@ 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()); + } + /** * 已执行 APPLY 的回滚必须标记为可重试补偿态,区别于阻断网络延迟 APPLY 的未确认回滚态。 */ @@ -93,6 +140,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 的幂等库存请求。 * From 4ad183d55edd3a72c1d2deb4f677749c9bcbd85c Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Mon, 14 Sep 2026 22:44:27 +0800 Subject: [PATCH 02/12] =?UTF-8?q?chore:=E9=87=8D=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E6=9E=84=E5=BB=BA,=E4=B8=8A=E6=9D=A1=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E8=BF=87=E9=95=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 6470e222d1ebf2a2f396312d6d69dff3efff791b Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Mon, 14 Sep 2026 22:56:22 +0800 Subject: [PATCH 03/12] =?UTF-8?q?fix=EF=BC=9A=E5=BA=93=E5=AD=98=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=A4=B1=E8=B4=A5=EF=BC=8C=E8=AF=B7=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/CanteenSpuServiceImpl.java | 5 +- .../mapper/CanteenDailyMenuMapper.xml | 20 ++++++- .../impl/CanteenSpuServiceImplTest.java | 59 ++++++++++++++++++- 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java index 6e166fc78..58bec30fb 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java @@ -318,8 +318,9 @@ public class CanteenSpuServiceImpl extends ServiceImpl 0 ? null : item.getBeforeStock(); updated = offline diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml index 6b81757e7..82626312c 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml @@ -324,17 +324,26 @@ delta 与旧接口一致:下单扣减传负数、售后回传正数。 实际改写销量:sold = sold - delta(扣减时 sold 增加)。 expected 为操作前「剩余」快照(配额 - 销量)。 + 正增量恢复把销量钳制到 0 为止:菜谱重导或按订单重算销量后 sold 可能小于恢复量, + 退款/补偿不能被该统计口径阻断;负增量扣减方向恒增,GREATEST 不改变原语义。 --> 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} + + + AND GREATEST(IFNULL(online_sold, 0) - #{delta}, 0) <= IFNULL(online_stock, 600) + + AND IFNULL(online_sold, 0) - #{delta} >= 0 AND IFNULL(online_sold, 0) - #{delta} <= IFNULL(online_stock, 600) + + AND (IFNULL(online_stock, 600) - IFNULL(online_sold, 0)) = #{expected} @@ -342,14 +351,21 @@ 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} + + + AND GREATEST(IFNULL(offline_sold, 0) - #{delta}, 0) <= IFNULL(offline_stock, 600) + + AND IFNULL(offline_sold, 0) - #{delta} >= 0 AND IFNULL(offline_sold, 0) - #{delta} <= IFNULL(offline_stock, 600) + + AND (IFNULL(offline_stock, 600) - IFNULL(offline_sold, 0)) = #{expected} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java b/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java index 6ce609ed2..d4045f9b4 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java @@ -1,7 +1,9 @@ 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; @@ -18,6 +20,7 @@ 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; @@ -43,6 +46,9 @@ class CanteenSpuServiceImplTest { /** 基础 SPU Mapper,用于验证库存增量是否真实执行。 */ private CanteenSpuMapper spuMapper; + /** 每日菜谱 Mapper,用于验证分池正增量恢复的剩余匹配条件。 */ + private CanteenDailyMenuMapper dailyMenuMapper; + /** Redis 字符串操作,用于模拟和验证库存业务状态迁移。 */ private ValueOperations valueOperations; @@ -52,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(); @@ -122,6 +129,54 @@ class CanteenSpuServiceImplTest { 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 的未确认回滚态。 */ From 6d43d262b92b69976f3ba6660cc9b0278b5e11b5 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Mon, 14 Sep 2026 23:16:47 +0800 Subject: [PATCH 04/12] =?UTF-8?q?fix:=E6=AF=8F=E6=97=A5=E8=8F=9C=E8=B0=B1?= =?UTF-8?q?=E9=80=80=E6=AC=BE=E6=81=A2=E5=A4=8D=E5=BA=93=E5=AD=98=E6=94=BE?= =?UTF-8?q?=E5=BC=80=E5=85=A8=E9=83=A8=E8=BE=B9=E7=95=8C=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?,=E9=94=80=E9=87=8FGREATEST=E9=92=B30,=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=A2=AB=E7=BB=9F=E8=AE=A1=E5=8F=A3=E5=BE=84=E9=98=BB=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/CanteenSpuServiceImpl.java | 6 ++--- .../mapper/CanteenDailyMenuMapper.xml | 23 ++++++------------- 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java index 58bec30fb..3549d165c 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java @@ -318,9 +318,9 @@ public class CanteenSpuServiceImpl extends ServiceImpl 0 ? null : item.getBeforeStock(); updated = offline diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml index 82626312c..7423d4e5c 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml @@ -324,8 +324,9 @@ delta 与旧接口一致:下单扣减传负数、售后回传正数。 实际改写销量:sold = sold - delta(扣减时 sold 增加)。 expected 为操作前「剩余」快照(配额 - 销量)。 - 正增量恢复把销量钳制到 0 为止:菜谱重导或按订单重算销量后 sold 可能小于恢复量, - 退款/补偿不能被该统计口径阻断;负增量扣减方向恒增,GREATEST 不改变原语义。 + 正增量恢复不带任何边界条件:SET 已把销量钳制到 0 为止(菜谱重导或按订单重算销量后 + sold 可能小于恢复量,退款/补偿不能被统计口径阻断),配额为 0 等脏数据同样不得阻断; + 负增量扣减保留原「不扣成负销量、不超配额」边界,语义不变。 --> UPDATE canteen_daily_menu @@ -335,15 +336,10 @@ AND start_time = #{mealDate} AND hour_type = #{hourType} AND spu_id = #{spuId} - - - AND GREATEST(IFNULL(online_sold, 0) - #{delta}, 0) <= IFNULL(online_stock, 600) - - + AND IFNULL(online_sold, 0) - #{delta} >= 0 AND IFNULL(online_sold, 0) - #{delta} <= IFNULL(online_stock, 600) - - + AND (IFNULL(online_stock, 600) - IFNULL(online_sold, 0)) = #{expected} @@ -357,15 +353,10 @@ AND start_time = #{mealDate} AND hour_type = #{hourType} AND spu_id = #{spuId} - - - AND GREATEST(IFNULL(offline_sold, 0) - #{delta}, 0) <= IFNULL(offline_stock, 600) - - + AND IFNULL(offline_sold, 0) - #{delta} >= 0 AND IFNULL(offline_sold, 0) - #{delta} <= IFNULL(offline_stock, 600) - - + AND (IFNULL(offline_stock, 600) - IFNULL(offline_sold, 0)) = #{expected} From 5473250c3c0f9dad8dead35e4bbc4954954d4947 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 15 Sep 2026 13:32:05 +0800 Subject: [PATCH 05/12] =?UTF-8?q?feat=EF=BC=9A=E5=AF=BC=E5=85=A5=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=8D=A1=E7=9A=84=E9=97=AE=E9=A2=98=20feat=EF=BC=9A?= =?UTF-8?q?=E4=BA=BA=E5=B7=A5=E5=AE=A1=E6=A0=B8=E4=B8=8D=E9=80=80=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/order/HardAfterSaleServiceImpl.java | 38 +++++-- .../bsd/pjcloud/admin/api/dto/UserDTO.java | 6 ++ .../pjcloud/admin/service/SysUserService.java | 9 ++ .../impl/SysUserImportExportServiceImpl.java | 101 ++++++++++++++++-- .../service/impl/SysUserServiceImpl.java | 57 +++++++--- .../src/main/resources/application.yml | 13 +++ .../src/main/resources/application.yml | 15 ++- 7 files changed, 208 insertions(+), 31 deletions(-) diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java index 62c43f4cc..54d107a55 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java @@ -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,14 @@ public class HardAfterSaleServiceImpl extends ServiceImpl 0 - ? RETURN_REVIEW_RESTORE_INVENTORY_BIZ_PREFIX + id + ":" + returnReviewCount : null; - restoreRefundInventory(order, id, inventoryBizId); + restoreRefundInventory(order, id, nextAgreeRestoreInventoryBizId(id)); } getSelf().refund(updateRefundObj.getId()); if (!canteenSpuInventoryOrder) { @@ -2016,6 +2024,24 @@ public class HardAfterSaleServiceImpl extends ServiceImpl食堂库存服务的幂等状态机会把失败回滚后的业务号标记为 ROLLED_BACK 终态, + * 同业务号后续恢复请求会被静默跳过(返回成功但不再回补库存)。后台强化退款每次 + * 新建售后单天然换号;人工审核复用同一售后单,必须按审核尝试轮次隔离业务号: + * 同一事务内轮次固定(远程重试不重复恢复库存),失败重试自增换号(不被终态吞掉)。

+ * + * @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 库存。 * diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java index ca66205a8..fa9273a1d 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java @@ -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; + /** * 根据名字或者手机号来查询 */ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java index 6a20ba1e9..d4ac5c092 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java @@ -124,6 +124,15 @@ public interface SysUserService extends IService { */ Long saveUserWithoutAudit(UserDTO userDto); + /** + * 批量导入场景专用保存:操作人角色快照只解析一次后逐行落库,单条保存语义与 {@link #saveUser} 完全一致。 + *

本方法不自带事务边界,由调用方按批控制提交;配合 {@code UserDTO.passwordEncoded} + * 可复用调用方预先加密好的密码,避免逐行重复 BCrypt。

+ * + * @param users 已通过整表校验的用户创建参数列表 + */ + void saveImportUsers(List users); + /** * 生成未被占用的系统用户登录账号。 */ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserImportExportServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserImportExportServiceImpl.java index f3ff40a5c..1ce3b32ea 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserImportExportServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserImportExportServiceImpl.java @@ -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 roleList = sysRoleService.list(); List postList = sysPostService.list(); Map usernameCountMap = countNonBlankValues(excelVOList.stream().map(UserExcelVO::getUsername).toList()); + Set existingUsernames = loadExistingUsernames(excelVOList.stream() + .map(UserExcelVO::getUsername).filter(StrUtil::isNotBlank).distinct().toList()); Map importContextMap = new HashMap<>(); for (UserExcelVO excel : excelVOList) { Set errorMessages = new HashSet<>(); - boolean usernameExists = sysUserService.exists(Wrappers.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 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 excelVOList, Map importContextMap, + Map encodedPasswords, TransactionTemplate batchTemplate, AsyncTaskProgressReporter reporter) { + for (int from = 0; from < excelVOList.size(); from += IMPORT_BATCH_SIZE) { + List 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 loadExistingUsernames(List usernames) { + if (CollUtil.isEmpty(usernames)) { + return Set.of(); + } + Set existing = new HashSet<>(); + for (int from = 0; from < usernames.size(); from += USERNAME_QUERY_BATCH_SIZE) { + List batch = usernames.subList(from, Math.min(from + USERNAME_QUERY_BATCH_SIZE, usernames.size())); + existing.addAll(sysUserService.list(Wrappers.lambdaQuery().in(SysUser::getUsername, batch)) + .stream().map(SysUser::getUsername).toList()); + } + return existing; + } + + /** + * 并行预计算导入用户的初始密码密文(初始密码按导入协议为各自手机号),把 BCrypt 开销移出数据库事务。 + * + * @param excelVOList 整表校验通过的后台用户行 + * @return 手机号到加密密文的映射 + */ + private Map precomputePasswords(List 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 roles, List posts) { + private UserDTO buildBackendUser(UserExcelVO excel, SysDept department, List roles, List posts, + Map 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;空默认启用 */ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java index 778218cf2..314a60f0f 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java @@ -194,13 +194,46 @@ public class SysUserServiceImpl extends ServiceImpl 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 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 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 applyUserRoles, boolean roleAdmin, + List nonAdminNewRoleIds) { } /** @@ -210,27 +243,23 @@ public class SysUserServiceImpl extends ServiceImpl 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 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 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); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/application.yml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/application.yml index 0a91b09f5..3e2337d62 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/application.yml +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/application.yml @@ -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: diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/application.yml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/application.yml index db282b566..ef73a250e 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/application.yml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/application.yml @@ -15,4 +15,17 @@ spring: config: import: - optional:nacos:application-@profiles.active@.yml - - optional:nacos:${spring.application.name}-@profiles.active@.yml \ No newline at end of file + - 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 \ No newline at end of file From 51a1d1a38aac8d33d622f0aed9a587bad6a64447 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 15 Sep 2026 14:00:16 +0800 Subject: [PATCH 06/12] =?UTF-8?q?feat:=E4=BC=98=E5=8C=96=E9=80=80=E5=9B=9E?= =?UTF-8?q?=E5=88=B0=E5=AE=A1=E6=A0=B8=E5=89=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../order/HardAfterSaleController.java | 11 ++-- .../hard/service/HardAfterSaleService.java | 5 +- .../impl/order/HardAfterSaleServiceImpl.java | 66 ++++++++++++++----- 3 files changed, 61 insertions(+), 21 deletions(-) diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java index 9f8b3a75e..f5ec1f8f5 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java @@ -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 returnToReview(@RequestParam Long id) { - return R.ok(hardAfterSaleService.returnToReview(SecurityUtils.getUser().getId(), id)); + public R returnToReview(@RequestParam Long id, + @RequestParam(value = "deductStock", defaultValue = "true") Boolean deductStock) { + return R.ok(hardAfterSaleService.returnToReview(SecurityUtils.getUser().getId(), id, deductStock)); } @Operation(summary = "分页查询", description = "分页查询") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java index 5913bdd38..79c0b18c1 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java @@ -119,13 +119,14 @@ public interface HardAfterSaleService extends IService { 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); /** * 校验订单是否可以退款 diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java index 54d107a55..fffd5cbe1 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java @@ -105,6 +105,9 @@ public class HardAfterSaleServiceImpl extends ServiceImpl 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); @@ -985,15 +994,21 @@ public class HardAfterSaleServiceImpl extends ServiceImpl updateWrapper = new LambdaUpdateWrapper<>(); @@ -1007,9 +1022,10 @@ public class HardAfterSaleServiceImpl extends ServiceImpl跳过时库存停留在已恢复态,后续再次同意只退款不恢复库存,保证整轮退回-重审 + * 的库存净效果仍为一次恢复。标记持久化在售后日志 content 后缀,事务回滚随之撤销。

+ * + * @param afterSaleId 售后单 ID + * @return 最近一次退回审核未扣减库存时返回 {@code true} + */ + private boolean isStockKeptByLastReturnToReview(Long afterSaleId) { + HardAfterSaleLogEntity lastReturn = hardAfterSaleLogMapper.selectOne(new LambdaQueryWrapper() + .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()); From aa8e823741d553b0673fcbc643f019abf9f8da6d Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 15 Sep 2026 15:00:20 +0800 Subject: [PATCH 07/12] =?UTF-8?q?feat:=E5=85=85=E5=80=BC=E5=A4=A7=E9=87=8F?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + .../admin/api/dto/StudentSchoolNoDTO.java | 46 +++ .../admin/api/dto/StudentUserMatchDTO.java | 29 ++ .../admin/api/feign/RemoteUserService.java | 20 + .../admin/controller/SysUserController.java | 100 +++++ .../impl/WalletActiveImportServiceImpl.java | 81 +++- .../WalletRechargeApplyImportServiceImpl.java | 369 +++++++++++------ ...letRechargeApplyImportServiceImplTest.java | 379 ++++++++++++++++++ 8 files changed, 896 insertions(+), 129 deletions(-) create mode 100644 pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/StudentSchoolNoDTO.java create mode 100644 pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/StudentUserMatchDTO.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImplTest.java diff --git a/AGENTS.md b/AGENTS.md index aca325182..893e0b0d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,7 @@ ## 开发规则 +- **新增源码文件不写版权/许可注释头**(`Copyright (c) pjcloud` 开头的大段 `/* */` 块)。既有文件里的此类注释头保持原样,不主动清理、不扩散到新文件。 - 修改前先确认所属业务模块及其 `api` / `biz` 边界,沿用现有 Controller、Service、Mapper、DTO、VO、Feign Client、异常处理、鉴权和统一响应结构。 - 跨服务调用优先遵循项目现有 OpenFeign 封装、服务名、请求模型、降级与异常处理方式,不得绕过已有边界直接耦合其他服务内部实现。 - Redis 使用必须复用已有缓存工具、序列化方式、键命名规则和过期策略。新增或修改缓存逻辑时,必须考虑缓存穿透、数据一致性、失效时机和空值处理。 diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/StudentSchoolNoDTO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/StudentSchoolNoDTO.java new file mode 100644 index 000000000..880bee2d8 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/StudentSchoolNoDTO.java @@ -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); + } +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/StudentUserMatchDTO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/StudentUserMatchDTO.java new file mode 100644 index 000000000..84366e53d --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/StudentUserMatchDTO.java @@ -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; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java index 64f1da0cd..b17eb33b5 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java @@ -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 getStudentBySchoolNameAndStudentNo(@RequestParam("schoolName") String schoolName, @RequestParam("studentNo") String studentNo); + /** + * 按手机号批量查询用户,供大文件 Excel 导入同步校验阶段一次定位多行用户。 + * + * @param mobiles 去重后的手机号列表,调用方应控制单次数量 + * @return 命中的用户列表;未命中的手机号不返回记录 + */ + @PostMapping("/user/get-users-by-mobiles") + R> getUsersByMobiles(@RequestBody List mobiles); + + /** + * 按学校和学号组合键批量查询学生用户,供大文件 Excel 导入同步校验阶段一次定位多行学生。 + * + * @param keys 学校和学号组合键列表,调用方应控制单次数量 + * @return 命中的学生用户资料,携带定位键便于回填;未命中的组合键不返回记录 + */ + @PostMapping("/user/get-students-by-school-and-no") + R> getStudentsBySchoolNameAndStudentNoBatch(@RequestBody List keys); + /** * 根据用户ID获取用户 */ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java index 5e6e00f3e..b15b9f152 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java @@ -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> getUsersByMobiles(@RequestBody List mobiles) { + if (CollectionUtils.isEmpty(mobiles)) { + return R.ok(List.of()); + } + List distinctMobiles = mobiles.stream() + .filter(StrUtil::isNotBlank) + .map(String::trim) + .distinct() + .toList(); + List users = new ArrayList<>(); + // 按 1000 一批执行 IN 查询,避免超出 SQL 长度或全表扫描 + for (List batch : ListUtil.partition(distinctMobiles, 1000)) { + users.addAll(userService.list(Wrappers.query().lambda().in(SysUser::getPhone, batch))); + } + return R.ok(users); + } + + /** + * 按学校和学号组合键批量查询学生用户,供大文件 Excel 导入在同步校验阶段一次定位多行学生。 + * + * @param keys 学校和学号组合键列表,服务端按学号分批查询后精确匹配学校名称 + * @return 命中的学生定位资料,携带定位键便于调用方回填;未命中的组合键不返回记录 + */ + @PostMapping("/get-students-by-school-and-no") + public R> getStudentsBySchoolNameAndStudentNoBatch(@RequestBody List keys) { + if (CollectionUtils.isEmpty(keys)) { + return R.ok(List.of()); + } + Set 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 students = new ArrayList<>(); + for (List batch : ListUtil.partition(distinctKeys.stream().map(StudentSchoolNoDTO::getStudentNo).distinct().toList(), 1000)) { + students.addAll(sysUserStudentService.list(Wrappers.query().lambda() + .in(SysUserStudentEntity::getStudentNo, batch))); + } + Map 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 userIds = distinctKeys.stream() + .map(key -> studentByKey.get(buildSchoolStudentKey(key.getSchoolName(), key.getStudentNo()))) + .filter(Objects::nonNull) + .map(SysUserStudentEntity::getUserId) + .distinct() + .toList(); + Map userById = userIds.isEmpty() ? Map.of() + : userService.listByIds(userIds).stream() + .collect(Collectors.toMap(SysUser::getUserId, Function.identity(), (first, ignored) -> first)); + List 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> getUserByNameOrMobile(@RequestParam("keyword") String keyword) { diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveImportServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveImportServiceImpl.java index dc1ca5246..1333999ea 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveImportServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveImportServiceImpl.java @@ -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,9 @@ public class WalletActiveImportServiceImpl implements WalletActiveImportService /** 允许导入的饭卡卡号长度,与既有开卡导入规则保持一致。 */ private static final Set CARD_NUMBER_LENGTHS = Set.of(8, 10, 12); + /** 批量用户定位单次远程请求携带的最大键数量,防止 SQL 过长。 */ + private static final int LOOKUP_BATCH_SIZE = 1000; + /** 用户远程服务,用于按默认模板或学生身份条件精确定位系统用户。 */ private final RemoteUserService remoteUserService; @@ -88,6 +94,9 @@ public class WalletActiveImportServiceImpl implements WalletActiveImportService }); Map phoneCounts = countValues(effectiveRows.stream().map(WalletImportActiveDto::getUserMobile).toList()); Map cardCounts = countValues(effectiveRows.stream().map(WalletImportActiveDto::getCardNumber).toList()); + // 一次性批量定位全部用户,避免逐行 Feign 导致大文件同步校验超时 + Map> usersByMobile = loadUsersByMobile( + effectiveRows.stream().map(WalletImportActiveDto::getUserMobile).toList()); List validatedRows = new ArrayList<>(); for (WalletImportActiveDto row : effectiveRows) { Set rowErrors = new LinkedHashSet<>(); @@ -99,8 +108,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 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("用户不存在"); } } @@ -139,6 +152,8 @@ public class WalletActiveImportServiceImpl implements WalletActiveImportService Map studentCounts = countValues(effectiveRows.stream() .map(row -> buildStudentKey(row.getSchoolName(), row.getStudentNo())).toList()); Map cardCounts = countValues(effectiveRows.stream().map(WalletStudentImportActiveDto::getCardNumber).toList()); + // 一次性批量定位全部学生,避免逐行 Feign 导致大文件同步校验超时 + Map studentByKey = loadStudentsByKey(effectiveRows); List validatedRows = new ArrayList<>(); for (WalletStudentImportActiveDto row : effectiveRows) { Set rowErrors = new LinkedHashSet<>(); @@ -150,17 +165,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); } @@ -344,13 +359,51 @@ public class WalletActiveImportServiceImpl implements WalletActiveImportService } /** - * 从 Feign 统一响应中安全提取用户,失败响应或空数据均按未找到处理。 + * 按手机号去重分批调用远程批量查询,构建手机号到候选用户的映射。 * - * @param response UPMS 用户查询响应 - * @return 用户实体;响应失败或数据为空时返回 {@code null} + * @param mobiles 当前 Excel 全部行的手机号(允许重复与空值) + * @return 手机号到候选用户列表的映射;查询失败的批次按未命中处理,由行级校验统一提示 */ - private SysUser extractUser(R response) { - return response == null || !response.isOk() || response.getData() == null ? null : response.getData().getSysUser(); + private Map> loadUsersByMobile(List mobiles) { + List distinctMobiles = mobiles.stream() + .filter(StrUtil::isNotBlank) + .distinct() + .toList(); + Map> usersByMobile = new HashMap<>(); + for (List batch : ListUtil.partition(distinctMobiles, LOOKUP_BATCH_SIZE)) { + R> 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 loadStudentsByKey(List rows) { + List keys = rows.stream() + .filter(row -> ObjectUtil.isAllNotEmpty(row.getSchoolName(), row.getStudentNo())) + .map(row -> new StudentSchoolNoDTO(row.getSchoolName(), row.getStudentNo())) + .distinct() + .toList(); + Map studentByKey = new HashMap<>(); + for (List batch : ListUtil.partition(keys, LOOKUP_BATCH_SIZE)) { + R> 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; } /** diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImpl.java index 860dffe5d..c87c54685 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImpl.java @@ -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 导入服务实现。 - *

负责默认用户模板校验、申请或发票创建及导入明细落库,不承载申请 CRUD 与审批逻辑。

+ *

同步阶段完成整表解析、批量用户定位和业务校验并立即返回逐行错误;校验全部通过后提交公共异步事务任务, + * 后台创建申请与导入明细并逐批报告进度,避免大文件导入触发网关超时。

*/ @Service @RequiredArgsConstructor @@ -48,6 +56,18 @@ 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; + /** 充值/扣款申请 Mapper,用于创建不绑定发票的申请主记录。 */ private final WalletRechargeApplyMapper walletRechargeApplyMapper; @@ -57,133 +77,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 rows, BindingResult bindingResult, String bizType) { validateBizType(bizType, false); ImmutablePair, List> 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 rows, BindingResult bindingResult, String bizType) { validateBizType(bizType, true); ImmutablePair, List> 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 rows, BindingResult bindingResult, String bizType) { validateBizType(bizType, false); ImmutablePair, List> 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 rows, BindingResult bindingResult, String bizType) { validateBizType(bizType, true); ImmutablePair, List> 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 rows, BindingResult bindingResult) { ImmutablePair, List> pair = validateExcelData(rows, bindingResult, true); if (ObjectUtil.isNotEmpty(pair.getLeft())) { return R.failed(pair.getLeft()); } List 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 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 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 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 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 rows, Integer totalPrice, AsyncTaskProgressReporter reporter) { + PjcloudUser loginUser = SecurityUtils.getUser(); WalletRechargeInvoiceEntity invoice = walletRechargeInvoiceService.createBaseInvoice(loginUser, totalPrice); if (invoice == null) { throw new IllegalStateException("导入失败"); } - List 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 rows, String bizType, boolean resetBalance) { + private Long insertApply(String bizType) { PjcloudUser loginUser = SecurityUtils.getUser(); WalletRechargeApplyEntity apply = new WalletRechargeApplyEntity(); apply.setDeptId(loginUser.getDeptId()); @@ -192,51 +296,22 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply if (walletRechargeApplyMapper.insert(apply) <= 0) { throw new IllegalStateException("导入失败"); } - List 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 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 details, AsyncTaskProgressReporter reporter) { + int processed = 0; + for (List batch : ListUtil.partition(details, SAVE_BATCH_SIZE)) { + walletRechargeImportService.saveBatch(batch, SAVE_BATCH_SIZE); + processed += batch.size(); + reporter.advance("已导入 " + processed + "/" + details.size() + " 行"); } - List 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 +336,7 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply } /** - * 校验默认用户模板并补齐用户、部门、分值金额及财务标签字典值。 + * 校验默认用户模板并批量补齐用户、部门、分值金额及财务标签字典值。 * * @param rows Excel 原始数据行 * @param bindingResult 公共 Excel 监听器生成的逐行错误 @@ -271,24 +346,25 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply @SuppressWarnings("unchecked") private ImmutablePair, List> validateExcelData( List rows, BindingResult bindingResult, boolean validatePrice) { - if (ObjectUtil.isEmpty(rows)) { - throw new IllegalArgumentException("读取excel数据错误"); - } - List effectiveRows = rows.stream() + List errors = getErrorMessageList(bindingResult); + List 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内容为空"))) : errors, null); } - List errors = getErrorMessageList(bindingResult); resolveTransactionTagValues(effectiveRows, errors); - List userPhones = effectiveRows.stream().map(WalletRechargeExcelDto::getUserMobile).toList(); + // 预统计手机号出现次数,替代逐行全表扫描的重复校验 + Map phoneCounts = countValues(effectiveRows.stream().map(WalletRechargeExcelDto::getUserMobile).toList()); + // 一次性批量定位全部用户,避免逐行 Feign 导致大文件同步校验超时 + Map> usersByMobile = loadUsersByMobile( + effectiveRows.stream().map(WalletRechargeExcelDto::getUserMobile).toList()); for (WalletRechargeExcelDto row : effectiveRows) { Set 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()) { @@ -299,7 +375,7 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply } /** - * 校验学生模板并按学校、学号补齐用户及部门信息。 + * 校验学生模板并按学校、学号批量补齐学生用户及部门信息。 * * @param rows 学生 Excel 原始数据行 * @param bindingResult 公共 Excel 监听器生成的逐行错误 @@ -309,27 +385,24 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply @SuppressWarnings("unchecked") private ImmutablePair, List> validateStudentExcelData( List rows, BindingResult bindingResult, boolean validatePrice) { - if (ObjectUtil.isEmpty(rows)) { - throw new IllegalArgumentException("读取excel数据错误"); - } - List effectiveRows = rows.stream() + List errors = getErrorMessageList(bindingResult); + List 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内容为空"))) : errors, null); } - List errors = getErrorMessageList(bindingResult); resolveStudentTransactionTagValues(effectiveRows, errors); - List studentKeys = effectiveRows.stream() - .map(row -> buildStudentKey(row.getSchoolName(), row.getStudentNo())) - .toList(); + Map studentCounts = countValues(effectiveRows.stream() + .map(row -> buildStudentKey(row.getSchoolName(), row.getStudentNo())).toList()); + Map studentByKey = loadStudentsByKey(effectiveRows); for (WalletStudentRechargeExcelDto row : effectiveRows) { Set 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()) { @@ -339,6 +412,54 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply return errors.isEmpty() ? ImmutablePair.of(null, effectiveRows) : ImmutablePair.of(errors, null); } + /** + * 按手机号去重分批调用远程批量查询,构建手机号到候选用户的映射。 + * + * @param mobiles 当前 Excel 全部行的手机号(允许重复与空值) + * @return 手机号到候选用户列表的映射;查询失败的批次按未命中处理,由行级校验统一提示 + */ + private Map> loadUsersByMobile(List mobiles) { + List distinctMobiles = mobiles.stream() + .filter(Validator::isMobile) + .distinct() + .toList(); + Map> usersByMobile = new HashMap<>(); + for (List batch : ListUtil.partition(distinctMobiles, LOOKUP_BATCH_SIZE)) { + R> 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 loadStudentsByKey(List rows) { + List 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 studentByKey = new HashMap<>(); + for (List batch : ListUtil.partition(keys, LOOKUP_BATCH_SIZE)) { + R> 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 +505,13 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply } /** - * 按姓名和手机号精确定位默认用户,并把用户及部门 ID 写回导入行。 + * 按姓名和手机号从批量查询结果中精确定位默认用户,并把用户及部门 ID 写回导入行。 * * @param row 当前 Excel 数据行 + * @param usersByMobile 批量查询得到的手机号到候选用户映射 * @param errors 当前行错误集合 */ - private void resolveDefaultUser(WalletRechargeExcelDto row, Set errors) { + private void resolveDefaultUser(WalletRechargeExcelDto row, Map> usersByMobile, Set errors) { if (ObjectUtil.isEmpty(row.getUsername())) { errors.add("请输入用户名"); return; @@ -402,23 +524,28 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply errors.add("用户手机号格式不正确"); return; } - R response = remoteUserService.getUserByNameAndMobile(row.getUsername(), row.getUserMobile()); - Optional user = Optional.ofNullable(response).map(R::getData).map(UserInfo::getSysUser); - if (user.isEmpty() || !ObjectUtil.equals(user.get().getName(), row.getUsername())) { + List 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 errors) { + private void resolveStudentUser(WalletStudentRechargeExcelDto row, Map studentByKey, + Set errors) { if (ObjectUtil.isEmpty(row.getStudentName())) { errors.add("请输入学生姓名"); } @@ -431,22 +558,22 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply if (ObjectUtil.hasEmpty(row.getSchoolName(), row.getStudentNo())) { return; } - R response = remoteUserService.getStudentBySchoolNameAndStudentNo(row.getSchoolName(), row.getStudentNo()); - Optional 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 +583,18 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply return Objects.toString(schoolName, "") + "\u0000" + Objects.toString(studentNo, ""); } + /** + * 统计非空文本在 Excel 中的出现次数,用于重复项校验。 + * + * @param values 待统计文本列表 + * @return 文本到出现次数的映射 + */ + private Map countValues(List values) { + return values.stream() + .filter(StrUtil::isNotBlank) + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + } + /** * 获取公共 Excel 解析器写入 BindingResult 的逐行错误集合。 * @@ -465,7 +604,7 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply @SuppressWarnings("unchecked") private List getErrorMessageList(BindingResult bindingResult) { Object target = bindingResult == null ? null : bindingResult.getTarget(); - return target instanceof List ? (List) target : new java.util.ArrayList<>(); + return target instanceof List ? (List) target : new ArrayList<>(); } /** diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImplTest.java new file mode 100644 index 000000000..cde4e9bb6 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImplTest.java @@ -0,0 +1,379 @@ +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.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; + +/** + * 充值导入服务同步校验与异步任务提交测试。 + *

覆盖批量用户定位、逐行错误回显、空表显式提示和异步动作事务内落库行为。

+ */ +@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> 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 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 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 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 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 errors = assertInstanceOf(List.class, response.getData()); + assertTrue(errors.get(0).getErrors().contains("Excel内容为空")); + } + + /** + * 全部行被解析监听器拒绝时应回显监听器错误,而不是覆盖为通用提示。 + */ + @Test + void importDefaultListenerErrorsAreReturnedVerbatim() { + BindingResult bindingResult = mock(BindingResult.class); + List 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 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 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()); + } +} From bc2c7e2b06332ef4522d76501f3bcf242d58925b Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 15 Sep 2026 15:16:54 +0800 Subject: [PATCH 08/12] =?UTF-8?q?feat:=E5=AF=BC=E5=85=A5excel=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E9=94=99=E8=AF=AF=E6=9C=80=E5=A4=9A1000=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/WalletActiveImportServiceImpl.java | 25 ++++++++++++++--- .../WalletRechargeApplyImportServiceImpl.java | 27 ++++++++++++++++--- ...letRechargeApplyImportServiceImplTest.java | 20 ++++++++++++++ 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveImportServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveImportServiceImpl.java index 1333999ea..2d2b62b07 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveImportServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveImportServiceImpl.java @@ -55,6 +55,9 @@ public class WalletActiveImportServiceImpl implements WalletActiveImportService /** 批量用户定位单次远程请求携带的最大键数量,防止 SQL 过长。 */ private static final int LOOKUP_BATCH_SIZE = 1000; + /** 单次导入同步返回的最大错误条数,超出部分以汇总行提示,避免大文件全错时响应体过大。 */ + private static final int MAX_RETURNED_ERRORS = 1000; + /** 用户远程服务,用于按默认模板或学生身份条件精确定位系统用户。 */ private final RemoteUserService remoteUserService; @@ -84,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 -> { @@ -140,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 -> { @@ -235,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) { @@ -406,6 +409,22 @@ public class WalletActiveImportServiceImpl implements WalletActiveImportService return studentByKey; } + /** + * 截断超量的逐行错误,仅保留前 {@value #MAX_RETURNED_ERRORS} 条并追加一条汇总说明。 + * + * @param errors 完整校验错误集合 + * @return 不超过上限的错误列表;未超量时原样返回 + */ + private List capErrors(List errors) { + if (errors.size() <= MAX_RETURNED_ERRORS) { + return errors; + } + List capped = new ArrayList<>(errors.subList(0, MAX_RETURNED_ERRORS)); + capped.add(new ErrorMessage(null, Set.of( + "错误共 " + errors.size() + " 条,仅展示前 " + MAX_RETURNED_ERRORS + " 条,请修正后重新导入"))); + return capped; + } + /** * 统计非空文本在 Excel 中的出现次数。 * diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImpl.java index c87c54685..e3fc5e1ee 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImpl.java @@ -68,6 +68,9 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply /** 异步落库阶段单批保存的明细行数,配合进度报告器逐批推进。 */ private static final int SAVE_BATCH_SIZE = 500; + /** 单次导入同步返回的最大错误条数,超出部分以汇总行提示,避免大文件全错时响应体过大。 */ + private static final int MAX_RETURNED_ERRORS = 1000; + /** 充值/扣款申请 Mapper,用于创建不绑定发票的申请主记录。 */ private final WalletRechargeApplyMapper walletRechargeApplyMapper; @@ -352,7 +355,7 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply .toList(); if (effectiveRows.isEmpty()) { // 无有效行时优先回显解析期逐行错误(如字典未配置),确实为空表时给出明确提示 - return ImmutablePair.of(errors.isEmpty() ? List.of(new ErrorMessage(1L, Set.of("Excel内容为空"))) : errors, null); + return ImmutablePair.of(errors.isEmpty() ? List.of(new ErrorMessage(1L, Set.of("Excel内容为空"))) : capErrors(errors), null); } resolveTransactionTagValues(effectiveRows, errors); // 预统计手机号出现次数,替代逐行全表扫描的重复校验 @@ -371,7 +374,7 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply 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); } /** @@ -390,7 +393,7 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply .filter(row -> !ObjectUtil.isAllEmpty(row.getStudentName(), row.getSchoolName(), row.getStudentNo(), row.getPrice())) .toList(); if (effectiveRows.isEmpty()) { - return ImmutablePair.of(errors.isEmpty() ? List.of(new ErrorMessage(1L, Set.of("Excel内容为空"))) : errors, null); + return ImmutablePair.of(errors.isEmpty() ? List.of(new ErrorMessage(1L, Set.of("Excel内容为空"))) : capErrors(errors), null); } resolveStudentTransactionTagValues(effectiveRows, errors); Map studentCounts = countValues(effectiveRows.stream() @@ -409,7 +412,7 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply 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); } /** @@ -595,6 +598,22 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); } + /** + * 截断超量的逐行错误,仅保留前 {@value #MAX_RETURNED_ERRORS} 条并追加一条汇总说明。 + * + * @param errors 完整校验错误集合 + * @return 不超过上限的错误列表;未超量时原样返回 + */ + private List capErrors(List errors) { + if (errors.size() <= MAX_RETURNED_ERRORS) { + return errors; + } + List 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 的逐行错误集合。 * diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImplTest.java index cde4e9bb6..6eb337a8c 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImplTest.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImplTest.java @@ -43,6 +43,7 @@ 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; @@ -279,6 +280,25 @@ class WalletRechargeApplyImportServiceImplTest { assertTrue(errors.get(0).getErrors().contains("Excel内容为空")); } + /** + * 全部行都有错误时应只返回前 1000 条错误,并追加一条汇总说明行。 + */ + @Test + void importDefaultCapsReturnedErrorsAtThousand() { + List 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 errors = assertInstanceOf(List.class, response.getData()); + assertEquals(1001, errors.size()); + assertTrue(errors.get(1000).getErrors().toString().contains("仅展示前 1000 条")); + } + /** * 全部行被解析监听器拒绝时应回显监听器错误,而不是覆盖为通用提示。 */ From 7d9c29e247b66e235f27e4fd4beb69c6edde4edd Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 15 Sep 2026 17:42:30 +0800 Subject: [PATCH 09/12] =?UTF-8?q?feat:=E8=B4=AD=E4=B9=B0=E5=95=86=E5=93=81?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + ...260915_add_hard_order_parent_car_index.sql | 28 + .../hard/api/dto/order/HardCarRecordDto.java | 147 ++++ .../hard/api/vo/order/HardCarRecordVo.java | 290 +++++++ .../excel/HardCarRecordExportExcelVo.java | 148 ++++ .../order/HardCarRecordController.java | 109 +++ .../mapper/order/HardCarRecordMapper.java | 33 + .../service/HardCarRecordExportService.java | 21 + .../hard/service/HardCarRecordService.java | 28 + .../order/HardCarRecordExportServiceImpl.java | 804 ++++++++++++++++++ .../impl/order/HardCarRecordServiceImpl.java | 136 +++ .../mapper/order/HardCarRecordMapper.xml | 165 ++++ 12 files changed, 1912 insertions(+) create mode 100644 db/20260915_add_hard_order_parent_car_index.sql create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarRecordDto.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarRecordVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCarRecordExportExcelVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarRecordController.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarRecordMapper.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarRecordExportService.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarRecordService.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarRecordExportServiceImpl.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarRecordServiceImpl.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarRecordMapper.xml diff --git a/.gitignore b/.gitignore index 7e9076a93..a217649e7 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,9 @@ gen_code ### node ### node_modules +### 充值导入测试脚本(含 Excel 原始数据与本地配置) ### +scripts/recharge-import/ + ### AI 辅助工具 ### .video_agent .mimosa diff --git a/db/20260915_add_hard_order_parent_car_index.sql b/db/20260915_add_hard_order_parent_car_index.sql new file mode 100644 index 000000000..b9bb5850c --- /dev/null +++ b/db/20260915_add_hard_order_parent_car_index.sql @@ -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),无需新增。 +-- 以上判断依据上方诊断查询输出,重跑时可复核。 diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarRecordDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarRecordDto.java new file mode 100644 index 000000000..aa0d02c50 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarRecordDto.java @@ -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; + +/** + * 购物车商品记录管理端查询条件,以购物车商品明细为查询维度。 + * + *

记录页一行对应一条购物车商品快照,商品条件作用于 hard_car,其余订单、用户、部门和时间条件 + * 均通过关联的订单表过滤;身份条件交由 UPMS 用户接口转换为用户主键范围。

+ */ +@Data +public class HardCarRecordDto implements BaseUserIdentityQueryInterface { + + /** + * 商品主键筛选;来源于购物车快照 product_id,为空表示不限定商品。 + */ + private Long productId; + + /** + * 商品名称模糊筛选;作用于购物车快照的商品名称。 + */ + private String productName; + + /** + * 购物车商品行主键集合;列表勾选导出时优先限定这些明细行。 + */ + private List carIds; + + /** + * 订单业务号精确筛选。 + */ + private String orderNo; + + /** + * 订单类型协议编码;页面订单类型 Tab 使用单值,为空表示全部类型。 + */ + private String orderType; + + /** + * 订单状态协议编码集合;为空表示不限制订单状态。 + */ + private List orderStatusCodes; + + /** + * 支付方式协议编码集合;为空表示不限制支付方式。 + */ + private List orderPayTypeCodes; + + /** + * 用餐时间段协议编码集合;为空表示不限制餐段。 + */ + private List hourTypeCodes; + + /** + * 页面选择的商户类型编码集合;与服务端注入的设备类型权限条件分别保留,最终按交集过滤。 + */ + private List merchantTypeCodes; + + /** + * 服务端按当前用户设备类型权限注入的商户类型编码集合;与页面多选条件按交集过滤。 + */ + private List mchTypeCodes; + + /** + * 设备类型协议编码集合;为空表示不限制设备类型。 + */ + private List hardTypeCodes; + + /** + * 商户主键筛选;与商户多选条件叠加。 + */ + private Long merchantId; + + /** + * 商户主键集合;单商户时服务端会展开为自身加子商户范围。 + */ + private List mchIds; + + /** + * 单个购买用户主键筛选。 + */ + private Long userId; + + /** + * 购买用户主键集合;身份筛选转换结果也写入该字段。 + */ + private List userIds; + + /** + * 用户部门主键筛选(下拉选择部门)。 + */ + private Long userDeptId; + + /** + * 用户部门主键集合(左侧部门树勾选)。 + */ + private List userDeptIds; + + /** + * 部门树勾选是否包含子部门;控制 userDeptIds 使用 IN 或 NOT IN。 + */ + private Boolean isInclude; + + /** + * 订单下单时间范围。 + */ + private LocalDateTime[] createTimeList; + + /** + * 订单支付时间范围。 + */ + private LocalDateTime[] payTimeList; + + /** + * 订单核销时间范围。 + */ + private LocalDateTime[] verificationTimeList; + + /** + * 预计使用日期范围。 + */ + private LocalDateTime[] apptUseDateTimeList; + + /** + * 用户身份协议编码筛选集合;查询前转换为用户主键范围。 + */ + private List identityTypes; + + /** + * 学生身份扩展筛选条件。 + */ + private com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto studentInfo; + + /** + * 老师身份扩展筛选条件。 + */ + private com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto teacherInfo; + + /** + * 人脸录入状态筛选条件。 + */ + private String hasFace; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarRecordVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarRecordVo.java new file mode 100644 index 000000000..b80501e97 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarRecordVo.java @@ -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; + +/** + * 购物车商品记录管理端展示对象,一行对应一条购物车商品快照及其所属订单摘要。 + * + *

商品列来源于 hard_car 快照,订单列通过父购物车关联 hard_order 得到;用户、身份和商户展示资料 + * 由服务层批量装配。实现用户部门与身份展示契约后可直接复用通用身份装配器和前端身份组件。

+ */ +@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 identityTypes; + + /** + * 学生身份扩展资料,UPMS 装配结果。 + */ + private SysUserStudentDto studentInfo; + + /** + * 老师身份扩展资料,UPMS 装配结果。 + */ + private SysUserTeacherDto teacherInfo; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCarRecordExportExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCarRecordExportExcelVo.java new file mode 100644 index 000000000..a7f87e119 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCarRecordExportExcelVo.java @@ -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; + +/** + * 购物车商品记录导出行,按商品业务域组织二级表头。 + * + *

一行对应一条购物车商品快照及其所属订单摘要,仅保留业务核对所需的商品、用户、身份、 + * 订单和时间信息,隐藏内部主键及审计字段;金额字段统一使用元。该对象仅用于导出展示, + * 不参与数据库持久化和接口入参。

+ */ +@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; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarRecordController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarRecordController.java new file mode 100644 index 000000000..d3fe1b72f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarRecordController.java @@ -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; + +/** + * 购物车商品记录管理 + * + *

以购物车商品明细为查询维度的记录页接口,一行对应一条商品快照及其所属订单摘要; + * 只提供分页查询和异步导出,购物车表维护仍由 {@link HardCarController} 负责。

+ * + * @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 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 mchDtoIds = dto.getMchIds(); + if (ObjectUtil.isNotEmpty(mchDtoIds) && mchDtoIds.size() == 1) { + Long mchId = mchDtoIds.get(0); + List mchList = mchService.getByParentId(mchId).getData(); + if (ObjectUtil.isNotEmpty(mchList)) { + List mchIds = mchList.stream().map(MchManageVo::getId).toList(); + mchDtoIds.addAll(mchIds); + dto.setMchIds(mchDtoIds); + } + } + return dataScope; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarRecordMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarRecordMapper.java new file mode 100644 index 000000000..e694d6bd7 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarRecordMapper.java @@ -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 { + + /** + * 按商品记录筛选条件和数据范围分页查询购物车商品明细及其所属订单摘要。 + * + *

查询列保留 merchant_dept_id 供数据权限拦截器包装部门过滤;排序以订单创建时间倒序、 + * 订单主键倒序加购物车行主键倒序保证跨页稳定,列表与异步导出共用同一口径。

+ * + * @param page 分页参数 + * @param dto 商品、订单、用户、部门、时间及身份转换后的筛选条件 + * @param dataScope 后台商户部门数据范围 + * @return 当前页商品记录及符合相同筛选条件的总数 + */ + Page recordPage(Page page, @Param("dto") HardCarRecordDto dto, DataScope dataScope); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarRecordExportService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarRecordExportService.java new file mode 100644 index 000000000..698e561f7 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarRecordExportService.java @@ -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); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarRecordService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarRecordService.java new file mode 100644 index 000000000..e4b85f382 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarRecordService.java @@ -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 { + + /** + * 按商品记录筛选条件和数据范围分页查询购物车商品明细。 + * + *

身份筛选条件先转换为用户主键范围再进入 SQL;返回行包含所属订单摘要,并批量装配 + * 用户资料、商户名称和钱包类型,列表页与导出任务共用同一查询口径。

+ * + * @param page 分页参数 + * @param dto 商品、订单、用户、部门、时间及身份筛选条件 + * @param dataScope 后台商户部门数据范围 + * @return 已完成展示信息装配的商品记录分页结果 + */ + Page scopePageList(Page page, HardCarRecordDto dto, DataScope dataScope); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarRecordExportServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarRecordExportServiceImpl.java new file mode 100644 index 000000000..eb87f46ed --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarRecordExportServiceImpl.java @@ -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; + +/** + * 购物车商品记录异步导出服务实现。 + * + *

导出前冻结当前用户可见的设备类型、商户范围和身份筛选,后台任务按稳定顺序分页读取商品明细, + * 并在同一任务线程同步补充完整用户资料和商户名称,避免列表服务公共线程池丢失 Feign 登录凭证。 + * 使用 EasyExcel 自定义二级表头分片写入,完成后上传文件中心并返回下载地址。

+ */ +@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 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 流式写入自定义二级表头工作簿。 + * + *

文件先写入临时路径,只有工作簿完整关闭后才交给上传回调;每批写入后上报一次进度, + * 避免大批量记录长期占用请求线程和堆内存。

+ * + * @param query 已冻结的商品记录查询条件 + * @param firstPage 已用于取得总数的第一页,避免重复查询首批记录 + * @param total 导出开始时按相同条件统计的记录总数 + * @param reporter 异步任务进度报告器 + * @param fileConsumer 完整临时文件消费回调 + * @param 文件消费结果类型 + * @return 文件消费回调返回的结果 + */ + private R writeExportWorkbook(HardCarRecordDto query, Page firstPage, long total, + AsyncTaskProgressReporter reporter, + BatchExcelExportTemplate.FileConsumer 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 currentPage = firstPage; + while (processedTotal < total) { + List records = safeRecords(currentPage); + if (records.isEmpty()) { + break; + } + long remaining = total - processedTotal; + int recordCount = Math.toIntExact(Math.min(records.size(), remaining)); + List batch = records.subList(0, recordCount); + List 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 fetchExportPage(long current, long size, HardCarRecordDto query, boolean searchCount) { + Page page = new Page<>(current, size, searchCount); + Page recordPage = hardCarRecordMapper.recordPage(page, query, createRecordDataScope()); + disposeRows(recordPage.getRecords()); + return recordPage; + } + + /** + * 在导出任务线程中同步装配一个商品记录分片的用户资料和商户名称。 + * + * @param records 当前商品记录分片,最多为商品记录导出批大小 + */ + private void disposeRows(List records) { + if (records == null || records.isEmpty()) { + return; + } + userIdentitySupport.fillUserInfo(records, true); + List merchantIds = records.stream().map(HardCarRecordVo::getMerchantId) + .filter(Objects::nonNull).distinct().toList(); + Map merchantNameMap = fetchMerchantNameMap(merchantIds); + for (HardCarRecordVo item : records) { + if (StrUtil.isBlank(item.getMerchantName())) { + item.setMerchantName(merchantNameMap.get(item.getMerchantId())); + } + } + } + + /** + * 批量查询商品记录商户名称并校验钱包服务业务响应。 + * + * @param merchantIds 当前记录分片去重后的商户主键 + * @return 商户主键到名称的映射;记录未关联系统商户时返回空映射 + */ + private Map fetchMerchantNameMap(List merchantIds) { + if (merchantIds.isEmpty()) { + return Map.of(); + } + R> 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 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(); + } + + /** + * 将订单状态协议编码安全转换为中文描述。 + * + *

既有枚举解析方法对未知编码会抛异常,因此导出侧遍历枚举匹配,确保历史脏数据仍可导出。

+ * + * @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 mchIds = query.getMchIds(); + if (mchIds != null && mchIds.size() == 1) { + Long parentMchId = mchIds.get(0); + R> response = remoteMchService.getByParentId(parentMchId); + if (response == null || !response.isOk()) { + throw new IllegalStateException("查询导出子商户范围失败"); + } + List 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 列表元素类型 + * @return 独立列表副本;输入为空时返回空 + */ + private List copyList(List 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 safeRecords(Page 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); + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarRecordServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarRecordServiceImpl.java new file mode 100644 index 000000000..c0546025f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarRecordServiceImpl.java @@ -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; + +/** + * 购物车商品记录查询服务实现。 + * + *

身份筛选统一转换为用户主键范围后进入 SQL;当前页记录在查询返回后并行装配用户资料、 + * 商户名称和钱包类型,装配口径与订单列表保持一致。列表页和异步导出任务共用本实现, + * 保证两个入口的数据口径完全相同。

+ */ +@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 scopePageList(Page page, HardCarRecordDto dto, DataScope dataScope) { + applyIdentityQuery(dto); + Page 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 list) { + if (ObjectUtil.isEmpty(list)) { + return; + } + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + // 用户与身份资料 + CompletableFuture userFuture = CompletableFuture.runAsync(() -> { + RequestContextHolder.setRequestAttributes(requestAttributes); + userIdentitySupport.fillUserInfo(list, true); + }); + // 商户名称与 Logo + CompletableFuture mchFuture = CompletableFuture.runAsync(() -> { + RequestContextHolder.setRequestAttributes(requestAttributes); + List mchIds = list.stream().map(HardCarRecordVo::getMerchantId) + .filter(ObjectUtil::isNotEmpty).distinct().toList(); + if (ObjectUtil.isNotEmpty(mchIds)) { + List mchList = remoteMchService.getByIds(mchIds).getData(); + if (ObjectUtil.isNotEmpty(mchList)) { + WalletDataUtil.convertMchList(list, CollectionUtils.convertMap(mchList, MchManageVo::getId)); + } + } + }); + // 钱包类型 + CompletableFuture walletFuture = CompletableFuture.runAsync(() -> { + RequestContextHolder.setRequestAttributes(requestAttributes); + List walletIds = list.stream().map(HardCarRecordVo::getWalletId) + .filter(ObjectUtil::isNotEmpty).distinct().toList(); + if (ObjectUtil.isNotEmpty(walletIds)) { + List walletList = remoteWalletService.getWalletByIds(walletIds).getData(); + Map 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(); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarRecordMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarRecordMapper.xml new file mode 100644 index 000000000..e66e065d9 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarRecordMapper.xml @@ -0,0 +1,165 @@ + + + + + + + + From 94215c41297a51e3e5fb2177b49f77b897715b99 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 15 Sep 2026 18:23:19 +0800 Subject: [PATCH 10/12] =?UTF-8?q?feat=EF=BC=9A=E8=AE=A2=E5=8D=95=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E6=9D=83=E9=99=90=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pjcloud/hard/controller/order/HardOrderController.java | 4 ++++ .../hard/service/impl/order/HardOrderExportServiceImpl.java | 2 ++ .../src/main/resources/mapper/order/HardOrderMapper.xml | 2 ++ 3 files changed, 8 insertions(+) diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java index 8a593a958..d6eae4c35 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java @@ -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> 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()); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java index 93a4b2211..de86ea586 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java @@ -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; } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml index 38581d45e..bc617e69e 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml @@ -447,6 +447,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。 --> - + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml index bc617e69e..58a285933 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml @@ -596,6 +596,35 @@ + + + + and ( a.user_id = #{dto.appViewerUserId} + + or a.user_id in + + #{v} + + or ( a.merchant_type = '1' and a.sys_type = '2' diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplAppVisibilityTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplAppVisibilityTest.java new file mode 100644 index 000000000..1aec26bcb --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplAppVisibilityTest.java @@ -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); + } +}