From b0b5c89c0fe93bb66754a41e7d47d460f4888dae Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Mon, 14 Sep 2026 09:59:10 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=E6=B6=88=E8=B4=B9=E6=9C=BA=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E6=89=A3=E5=87=8F=E6=89=A3=E6=AF=8F=E6=97=A5=E8=8F=9C?= =?UTF-8?q?=E8=B0=B1=E7=9A=84=E7=BA=BF=E4=B8=8B=E5=BA=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/order/HardOrderServiceImpl.java | 134 ++++++++++++------ .../HardOrderServiceImplInventoryTest.java | 119 +++++++++++++++- 2 files changed, 204 insertions(+), 49 deletions(-) diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java index 04d410625..0e4ea7023 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java @@ -4643,30 +4643,28 @@ public class HardOrderServiceImpl extends ServiceImpl inventorySnapshot = new LinkedHashMap<>(); + Map dailyChanges; + Map basicChanges = Map.of(); - if (dailyMenu) { + if (markedDailyMenu) { + dailyChanges = stockChanges; if (mealDate == null || hourType == null) { throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD); } // 库存恢复可能在自动退款定时任务等无用户凭证的上下文执行,必须走 @NoToken 内部菜谱接口; // getCanteenList 是 AD 终端登录态接口,任务线程调用会被 424 拒绝。 - R> menuResult = remoteDailyMenuService.listForOrderValidate( - mealDate.toString(), hourType, order.getMerchantId()); - if (menuResult == null || !menuResult.isOk() || menuResult.getData() == null) { - throw new I18nBizException(menuResult == null ? ErrorCodes.HARD_CANTEEN_STOCK_NO_RESP - : ErrorCodes.HARD_CANTEEN_STOCK_QUERY_FAIL); - } - Map menuBySpu = menuResult.getData().stream() - .filter(Objects::nonNull) - .filter(row -> row.getSpuId() != null) - .collect(Collectors.toMap(DailyMenuAppVO::getSpuId, Function.identity(), (a, b) -> a)); + Map menuBySpu = loadDailyMenuForInventorySplit( + mealDate, hourType, order.getMerchantId(), false); for (Map.Entry entry : stockChanges.entrySet()) { DailyMenuAppVO row = menuBySpu.get(entry.getKey()); if (row == null) { @@ -4680,34 +4678,55 @@ public class HardOrderServiceImpl extends ServiceImpl> productResult = remoteCanteenSpuService - .getInnerSpuListByIds(new ArrayList<>(stockChanges.keySet())); - if (productResult == null || !productResult.isOk() || productResult.getData() == null) { - if (productResult == null) { - throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_NO_RESP); - } - if (StrUtil.isNotBlank(productResult.getMsg())) { - throw new RuntimeException(productResult.getMsg()); - } - throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_QUERY_FAIL); - } - Map productMap = productResult.getData().stream() - .filter(Objects::nonNull) - .filter(product -> product.getId() != null) - .collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first)); + // 设备购买当日菜谱:按订单日期餐段在售情况分流,日期或餐段缺失时全部按基础 SPU 库存处理。 + Map menuBySpu = loadDailyMenuForInventorySplit( + mealDate, hourType, order.getMerchantId(), true); + dailyChanges = new LinkedHashMap<>(); + Map unresolvedChanges = new LinkedHashMap<>(); for (Map.Entry entry : stockChanges.entrySet()) { - CanteenSpuEntity product = productMap.get(entry.getKey()); - if (product == null || !Objects.equals(product.getMerchantId(), order.getMerchantId())) { - throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD); + DailyMenuAppVO row = menuBySpu.get(entry.getKey()); + if (row == null) { + unresolvedChanges.put(entry.getKey(), entry.getValue()); + continue; } - int productType = product.getSpuType() == null ? 1 : product.getSpuType(); - if (productType != 1 && productType != 2) { - throw new I18nBizException(ErrorCodes.HARD_PRODUCT_MODE_BAD, product.getName()); + int channelRemain = onlineChannel ? dailyOnlineRemain(row) : dailyOfflineRemain(row); + if (entry.getValue() < 0 && channelRemain < Math.abs(entry.getValue())) { + throw new I18nBizException(ErrorCodes.HARD_PRODUCT_STOCK_LOW, row.getName()); } - if (product.getStock() == null || entry.getValue() < 0 && product.getStock() < Math.abs(entry.getValue())) { - throw new I18nBizException(ErrorCodes.HARD_PRODUCT_STOCK_LOW, product.getName()); + inventorySnapshot.put(entry.getKey(), channelRemain); + dailyChanges.put(entry.getKey(), entry.getValue()); + } + basicChanges = unresolvedChanges; + if (!basicChanges.isEmpty()) { + R> productResult = remoteCanteenSpuService + .getInnerSpuListByIds(new ArrayList<>(basicChanges.keySet())); + if (productResult == null || !productResult.isOk() || productResult.getData() == null) { + if (productResult == null) { + throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_NO_RESP); + } + if (StrUtil.isNotBlank(productResult.getMsg())) { + throw new RuntimeException(productResult.getMsg()); + } + throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_QUERY_FAIL); + } + Map productMap = productResult.getData().stream() + .filter(Objects::nonNull) + .filter(product -> product.getId() != null) + .collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first)); + for (Map.Entry entry : basicChanges.entrySet()) { + CanteenSpuEntity product = productMap.get(entry.getKey()); + if (product == null || !Objects.equals(product.getMerchantId(), order.getMerchantId())) { + throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD); + } + int productType = product.getSpuType() == null ? 1 : product.getSpuType(); + if (productType != 1 && productType != 2) { + throw new I18nBizException(ErrorCodes.HARD_PRODUCT_MODE_BAD, product.getName()); + } + if (product.getStock() == null || entry.getValue() < 0 && product.getStock() < Math.abs(entry.getValue())) { + throw new I18nBizException(ErrorCodes.HARD_PRODUCT_STOCK_LOW, product.getName()); + } + inventorySnapshot.put(entry.getKey(), product.getStock()); } - inventorySnapshot.put(entry.getKey(), product.getStock()); } } if (validateOnly) return; @@ -4717,19 +4736,48 @@ public class HardOrderServiceImpl extends ServiceImpl loadDailyMenuForInventorySplit(LocalDate mealDate, Integer hourType, + Long merchantId, boolean skipWhenUnknown) { + if (mealDate == null || hourType == null) { + if (skipWhenUnknown) { + return Map.of(); + } + throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD); + } + // 必须走 @NoToken 内部菜谱接口:设备支付回调和自动退款任务都没有用户凭证,登录态接口会 424。 + R> menuResult = remoteDailyMenuService.listForOrderValidate( + mealDate.toString(), hourType, merchantId); + if (menuResult == null || !menuResult.isOk() || menuResult.getData() == null) { + throw new I18nBizException(menuResult == null ? ErrorCodes.HARD_CANTEEN_STOCK_NO_RESP + : ErrorCodes.HARD_CANTEEN_STOCK_QUERY_FAIL); + } + return menuResult.getData().stream() + .filter(Objects::nonNull) + .filter(row -> row.getSpuId() != null) + .collect(Collectors.toMap(DailyMenuAppVO::getSpuId, Function.identity(), (a, b) -> a)); + } + /** * 当前 Hardware 本地事务回滚时反向撤销已经尝试的食堂基础 SPU 库存操作, * 覆盖远程成功但本地提交失败的窗口。 diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplInventoryTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplInventoryTest.java index cd865a940..09b96beaa 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplInventoryTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplInventoryTest.java @@ -188,25 +188,89 @@ class HardOrderServiceImplInventoryTest { } /** - * 验证设备购买链路(/hard-api/ad/create-order 产生的 BELOW+CANTEEN_SPU 购物车)扣基础 canteen_spu.stock: - * 请求不携带每日菜谱分池通道,也不查询每日菜谱接口——每日菜谱页面的线上/线下剩余不会变化。 + * 验证设备购买链路(/hard/hard-api/ad/create-order 的 BELOW+CANTEEN_SPU 购物车)对当日菜谱在册商品 + * 扣每日菜谱 OFFLINE 池:请求带 stockChannel=OFFLINE、日期餐段和线下剩余快照,不再扣 canteen_spu.stock。 */ @Test - void deviceCreateOrderDeductsBasicSpuStockWithoutDailyMenuPools() { + void deviceCreateOrderDeductsDailyMenuOfflinePoolWhenOnMenu() { prepareCart(1); + when(remoteDailyMenuService.listForOrderValidate(any(), any(), any())) + .thenReturn(R.ok(List.of(dailyMenuRow(8, 4)))); + when(remoteCanteenSpuService.updateSkuStockIdempotent(any())).thenReturn(R.ok()); + + HardOrderEntity order = deviceDailyMenuOrder(); + assertDoesNotThrow(() -> service.fastenerInventory(order, null, YesNoEnum.YES, null, null)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class); + verify(remoteCanteenSpuService).updateSkuStockIdempotent(captor.capture()); + CanteenSpuUpdateStockReqDTO request = captor.getValue(); + assertEquals("OFFLINE", request.getStockChannel()); + assertEquals("APPLY", request.getOperation()); + assertEquals("HARD_DAILY_MENU_ORDER:100", request.getBizId()); + assertEquals(300L, request.getMerchantId()); + CanteenSpuUpdateStockReqDTO.Item item = request.getItems().get(0); + assertEquals(11L, item.getId()); + assertEquals(-1, item.getIncrCount()); + assertEquals(4, item.getBeforeStock()); + assertEquals(LocalDate.of(2026, 9, 13), item.getMealDate()); + assertEquals(2, item.getHourType()); + verify(remoteCanteenSpuService, never()).getInnerSpuListByIds(any()); + } + + /** + * 验证设备购买不在当日菜谱的商品时仍扣基础 canteen_spu.stock:请求不携带分池通道。 + */ + @Test + void deviceCreateOrderDeductsBasicSpuStockWhenNotOnMenu() { + prepareCart(1); + when(remoteDailyMenuService.listForOrderValidate(any(), any(), any())) + .thenReturn(R.ok(List.of())); CanteenSpuEntity product = productWithStock(9996); when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(11L))).thenReturn(R.ok(List.of(product))); when(remoteCanteenSpuService.updateSkuStockIdempotent(any())).thenReturn(R.ok()); - assertDoesNotThrow(() -> service.fastenerInventory(cashierOrder(), null, YesNoEnum.YES, null, null)); + HardOrderEntity order = deviceDailyMenuOrder(); + assertDoesNotThrow(() -> service.fastenerInventory(order, null, YesNoEnum.YES, null, null)); ArgumentCaptor captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class); verify(remoteCanteenSpuService).updateSkuStockIdempotent(captor.capture()); CanteenSpuUpdateStockReqDTO request = captor.getValue(); assertNull(request.getStockChannel()); assertEquals(-1, request.getItems().get(0).getIncrCount()); - verify(remoteDailyMenuService, never()).listForOrderValidate(any(), any(), any()); - verify(remoteDailyMenuService, never()).getCanteenList(any(), any(), any(), any()); + assertEquals(9996, request.getItems().get(0).getBeforeStock()); + } + + /** + * 验证混合购物车按商品分流:在册商品扣每日菜谱 OFFLINE 池,不在册商品扣基础 canteen_spu.stock, + * 两次幂等调用共用同一业务号(食堂端状态键按渠道隔离,不会互相命中)。 + */ + @Test + void deviceCreateOrderSplitsMixedCartBetweenPools() { + prepareTwoProductCart(1, 2); + when(remoteDailyMenuService.listForOrderValidate(any(), any(), any())) + .thenReturn(R.ok(List.of(dailyMenuRow(8, 4)))); + CanteenSpuEntity product = productWithStock(5); + product.setId(12L); + when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(12L))).thenReturn(R.ok(List.of(product))); + when(remoteCanteenSpuService.updateSkuStockIdempotent(any())).thenReturn(R.ok()); + + HardOrderEntity order = deviceDailyMenuOrder(); + assertDoesNotThrow(() -> service.fastenerInventory(order, null, YesNoEnum.YES, null, null)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class); + verify(remoteCanteenSpuService, org.mockito.Mockito.times(2)).updateSkuStockIdempotent(captor.capture()); + CanteenSpuUpdateStockReqDTO dailyRequest = captor.getAllValues().stream() + .filter(r -> "OFFLINE".equals(r.getStockChannel())).findFirst().orElseThrow(); + CanteenSpuUpdateStockReqDTO basicRequest = captor.getAllValues().stream() + .filter(r -> r.getStockChannel() == null).findFirst().orElseThrow(); + assertEquals("HARD_DAILY_MENU_ORDER:100", dailyRequest.getBizId()); + assertEquals(11L, dailyRequest.getItems().get(0).getId()); + assertEquals(-1, dailyRequest.getItems().get(0).getIncrCount()); + assertEquals(4, dailyRequest.getItems().get(0).getBeforeStock()); + assertEquals("HARD_DAILY_MENU_ORDER:100", basicRequest.getBizId()); + assertEquals(12L, basicRequest.getItems().get(0).getId()); + assertEquals(-2, basicRequest.getItems().get(0).getIncrCount()); + assertEquals(5, basicRequest.getItems().get(0).getBeforeStock()); } /** @@ -318,6 +382,31 @@ class HardOrderServiceImplInventoryTest { when(hardCarService.getByParentCarId(200L)).thenReturn(List.of(car)); } + /** + * 准备双商品购物车:第一个商品可出现在当日菜谱,第二个不在菜谱,用于混合分流验证。 + * + * @param firstCount 第一个商品份数 + * @param secondCount 第二个商品份数 + */ + private void prepareTwoProductCart(int firstCount, int secondCount) { + HardParentCarEntity parentCar = HardParentCarEntity.builder() + .id(200L) + .productSource(HardProductSourceEnum.CANTEEN_SPU.getType()) + .build(); + HardCarEntity first = HardCarEntity.builder() + .parentCarId(200L) + .productId(11L) + .productCount(firstCount) + .build(); + HardCarEntity second = HardCarEntity.builder() + .parentCarId(200L) + .productId(12L) + .productCount(secondCount) + .build(); + when(hardCarService.getParentCarById(200L)).thenReturn(parentCar); + when(hardCarService.getByParentCarId(200L)).thenReturn(List.of(first, second)); + } + /** * 构造指定线上/线下剩余的每日菜谱行。 * @@ -365,6 +454,24 @@ class HardOrderServiceImplInventoryTest { .build(); } + /** + * 构造带日期餐段的设备购买订单:createOrder 会写入 apptUseDate=今天、hourType=请求餐段, + * 是 /hard/hard-api/ad/create-order 购买每日菜谱时的真实形态。 + * + * @return 关联 CANTEEN_SPU 购物车、固定日期餐段和商户的 BELOW 订单 + */ + private HardOrderEntity deviceDailyMenuOrder() { + return HardOrderEntity.builder() + .id(100L) + .sysType(HardTypeEnum.CANTEEN.getType()) + .orderType(HardOrderTypeEnum.BELOW.getType()) + .parentCarId(200L) + .merchantId(300L) + .apptUseDate(LocalDate.of(2026, 9, 13)) + .hourType("2") + .build(); + } + /** * 构造线上预定提交的每日菜谱订单。 * From 198352ef217484ad11f981b98e9089535ee5b35a Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Mon, 14 Sep 2026 11:09:32 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=E7=BA=BF=E4=B8=8A=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=A0=B8=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/20260914_add_mch_auto_verify.sql | 39 ++ .../order/HardOrderVerificationTypeEnum.java | 46 ++ .../HardAutoRefundAttemptResultEnum.java | 7 +- .../refund/HardAutoRefundTaskStatusEnum.java | 7 +- .../refund/HardAutoRefundTaskTypeEnum.java | 5 +- .../api/entity/order/HardOrderEntity.java | 19 + .../order/HardAutoVerifyRescanResultVo.java | 28 ++ .../order/HardAutoRefundTaskController.java | 39 ++ .../order/HardAutoRefundTaskMapper.java | 24 + .../service/HardAutoRefundTaskService.java | 17 + .../hard/service/HardAutoVerifyService.java | 30 ++ .../hard/service/HardOrderService.java | 10 + .../order/HardAutoRefundTaskServiceImpl.java | 217 ++++++++- .../impl/order/HardAutoVerifyServiceImpl.java | 434 ++++++++++++++++++ .../impl/order/HardOrderServiceImpl.java | 118 ++++- .../mapper/order/HardAutoRefundTaskMapper.xml | 90 ++++ .../mapper/order/HardOrderMapper.xml | 4 + .../HardAutoRefundTaskServiceImplTest.java | 131 +++++- .../order/HardAutoVerifyServiceImplTest.java | 201 ++++++++ .../HardOrderServiceImplVerificationTest.java | 48 ++ .../api/dto/mch/MchAutoRefundConfigDto.java | 12 + .../entity/mch/MchAutoRefundConfigEntity.java | 12 + .../mch/MchAutoRefundConfigServiceImpl.java | 44 +- 23 files changed, 1558 insertions(+), 24 deletions(-) create mode 100644 db/20260914_add_mch_auto_verify.sql create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderVerificationTypeEnum.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoVerifyRescanResultVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoVerifyService.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoVerifyServiceImpl.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoVerifyServiceImplTest.java diff --git a/db/20260914_add_mch_auto_verify.sql b/db/20260914_add_mch_auto_verify.sql new file mode 100644 index 000000000..f0eebea8a --- /dev/null +++ b/db/20260914_add_mch_auto_verify.sql @@ -0,0 +1,39 @@ +-- 每日菜谱预约订单过期未核销自动核销。 +-- 只增加自动核销配置、订单快照与核销方式字段,并扩展任务场景生成列;不回填历史订单。 +-- 执行前应确认目标字段/索引不存在;权限复用 wallet_mchManage_setting、hard_hardAfterSale_view、 +-- hard_hardAfterSale_adminRefund,无需新增 sys_menu 记录。 + +ALTER TABLE `pjcloudx_wallet`.`mch_auto_refund_config` + ADD COLUMN `auto_verify_enabled` char(1) NOT NULL DEFAULT '0' + COMMENT '过期未核销自动核销开关,0关闭,1开启' AFTER `expired_reserve_refund_restore_inventory`, + ADD COLUMN `auto_verify_days` int(11) DEFAULT NULL + COMMENT '支付后自动核销天数,允许1至365,开关开启时必填' AFTER `auto_verify_enabled`, + ADD COLUMN `auto_verify_restore_inventory` char(1) NOT NULL DEFAULT '1' + COMMENT '自动核销是否恢复线上池库存,0不恢复,1恢复' AFTER `auto_verify_days`; + +ALTER TABLE `pjcloudx_hardware`.`hard_order` + ADD COLUMN `auto_verify_config_id` bigint(20) DEFAULT NULL + COMMENT '支付成功时命中的自动核销配置ID快照,为空表示不参与自动核销' AFTER `expired_refund_restore_inventory`, + ADD COLUMN `auto_verify_deadline` datetime DEFAULT NULL + COMMENT '支付时间加配置天数后的自动核销触发时间' AFTER `auto_verify_config_id`, + ADD COLUMN `auto_verify_restore_inventory` char(1) DEFAULT NULL + COMMENT '自动核销退库存快照,0不恢复,1恢复线上池库存' AFTER `auto_verify_deadline`, + ADD COLUMN `verification_type` char(1) DEFAULT NULL + COMMENT '核销方式,1人工核销,2自动核销;历史已核销订单为空视为人工' AFTER `verification_time`; + +-- 自动核销任务复用 hard_auto_refund_task(任务场景30),按 order_id 承载场景唯一键: +-- 生成列必须同步扩展,否则场景30会落到 after_sale_id 为空,导致一单一任务约束失效。 +ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` + MODIFY COLUMN `task_type` char(10) NOT NULL DEFAULT '10' + COMMENT '任务场景,10用户免审售后,20过期预约退款,30过期未核销自动核销', + MODIFY COLUMN `status` char(10) NOT NULL + COMMENT '任务状态,10待执行,20等待重试,30开关关闭暂停,31配置不可用暂停,40重试耗尽,50成功,60订单已终态跳过', + MODIFY COLUMN `scene_biz_id` bigint(20) + GENERATED ALWAYS AS (CASE WHEN `task_type` IN ('20', '30') THEN `order_id` ELSE `after_sale_id` END) STORED + COMMENT '任务场景业务唯一值,系统生成,禁止业务代码写入'; + +ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_attempt_log` + MODIFY COLUMN `result_status` char(10) NOT NULL + COMMENT '尝试结果,10执行中,20成功,30失败,40因开关关闭跳过,50订单已终态跳过', + MODIFY COLUMN `after_sale_id` bigint(20) DEFAULT NULL + COMMENT '售后单ID,过期退款和自动核销任务为空'; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderVerificationTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderVerificationTypeEnum.java new file mode 100644 index 000000000..60d0a4282 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderVerificationTypeEnum.java @@ -0,0 +1,46 @@ +package com.bsd.pjcloud.hard.api.constant.order; + +import lombok.Getter; + +import java.util.Arrays; + +/** + * 预约订单核销方式。编码持久化到 hard_order.verification_type,用于区分人工核销与系统自动核销; + * 字段引入前已核销的历史订单该列为空,展示和统计时按人工核销处理。 + */ +@Getter +public enum HardOrderVerificationTypeEnum { + + /** 人工核销:POS 扫码、App/管理端手动核销和批量核销等由工作人员主动触发的核销。 */ + MANUAL("1", "人工核销"), + + /** 自动核销:自动核销任务在支付后超过配置天数仍未核销时由系统静默完成。 */ + AUTO("2", "自动核销"); + + /** 数据库存储的稳定核销方式编码。 */ + private final String type; + + /** 管理端展示名称。 */ + private final String description; + + /** + * 构造核销方式协议项。 + * + * @param type 数据库存储编码 + * @param description 展示名称 + */ + HardOrderVerificationTypeEnum(String type, String description) { + this.type = type; + this.description = description; + } + + /** + * 按持久化编码解析核销方式。 + * + * @param type 数据库存储编码 + * @return 匹配的核销方式;空值或未知编码返回 {@code null} + */ + public static HardOrderVerificationTypeEnum valueOfByType(String type) { + return Arrays.stream(values()).filter(item -> item.type.equals(type)).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundAttemptResultEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundAttemptResultEnum.java index c643ddf4f..7067676c8 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundAttemptResultEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundAttemptResultEnum.java @@ -26,7 +26,12 @@ public enum HardAutoRefundAttemptResultEnum { /** * 执行前发现当前开关关闭,未调用资金接口且不消耗自动尝试额度。 */ - SWITCH_DISABLED("40", "因开关关闭跳过"); + SWITCH_DISABLED("40", "因开关关闭跳过"), + + /** + * 自动核销任务执行前发现订单已人工核销、已退款或已取消,本次未执行核销且任务进入终态。 + */ + SKIPPED("50", "订单已终态跳过"); /** * 数据库存储的稳定结果编码。 diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskStatusEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskStatusEnum.java index 936d68ac5..6f83ac560 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskStatusEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskStatusEnum.java @@ -38,7 +38,12 @@ public enum HardAutoRefundTaskStatusEnum { /** * 钱包已幂等到账,售后和订单退款聚合状态均已完成更新。 */ - SUCCESS("50", "成功"); + SUCCESS("50", "成功"), + + /** + * 自动核销任务执行时订单已人工核销、已退款或已取消等终态,无需再核销;任务到此结束。 + */ + SKIPPED("60", "订单已终态跳过"); /** * 数据库和接口使用的稳定状态编码。 diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskTypeEnum.java index fa11785b3..b96010958 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskTypeEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskTypeEnum.java @@ -12,7 +12,10 @@ public enum HardAutoRefundTaskTypeEnum { AFTER_SALE("10", "售后申请自动退款"), /** 每日菜谱预约餐段结束后创建的系统过期退款任务。 */ - EXPIRED_RESERVE("20", "过期预约自动退款"); + EXPIRED_RESERVE("20", "过期预约自动退款"), + + /** 每日菜谱预约订单支付后超过配置天数未核销时创建的系统自动核销任务。 */ + AUTO_VERIFY("30", "过期未核销自动核销"); /** 数据库存储的稳定任务场景编码。 */ private final String type; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java index 1665a7198..9dc4f9031 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java @@ -175,6 +175,18 @@ public class HardOrderEntity extends Model { @Schema(description = "过期预约退款退库存快照") private String expiredRefundRestoreInventory; + /** 自动核销配置 ID 快照;仅支付成功且当时开关开启的每日菜谱预约订单写入,为空表示不参与自动核销。 */ + @Schema(description = "自动核销配置ID快照") + private Long autoVerifyConfigId; + + /** 支付时间加配置天数后的自动核销触发时间;任务按该时间到期执行。 */ + @Schema(description = "自动核销触发时间") + private LocalDateTime autoVerifyDeadline; + + /** 自动核销是否恢复每日菜谱线上池库存的订单快照,1恢复、0不恢复。 */ + @Schema(description = "自动核销退库存快照") + private String autoVerifyRestoreInventory; + /** * 订单设备类型 */ @@ -241,6 +253,13 @@ public class HardOrderEntity extends Model { @Schema(description = "订单核销时间") private LocalDateTime verificationTime; + /** + * 核销方式快照,对应 {@code HardOrderVerificationTypeEnum}:1人工核销、2自动核销; + * 字段引入前已核销的历史订单为空,按人工核销处理;退回核销前状态时清空。 + */ + @Schema(description = "核销方式,1人工核销,2自动核销") + private String verificationType; + /** * 订单取消时间(退款订单) */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoVerifyRescanResultVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoVerifyRescanResultVo.java new file mode 100644 index 000000000..3d49943ca --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoVerifyRescanResultVo.java @@ -0,0 +1,28 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 商户自动核销立即重扫结果,供管理端展示本轮快照刷新和任务补建数量。 + */ +@Data +@Schema(description = "自动核销立即重扫结果") +public class HardAutoVerifyRescanResultVo { + + /** 本轮读取的已支付、未核销且支付时间已超过配置天数的每日菜谱订单数量。 */ + @Schema(description = "扫描订单数量") + private Integer scannedOrderCount; + + /** 本轮首次补建的自动核销任务数量。 */ + @Schema(description = "新建任务数量") + private Integer createdTaskCount; + + /** 本轮按当前天数和退库存配置刷新触发时间的未尝试任务数量。 */ + @Schema(description = "刷新任务数量") + private Integer refreshedTaskCount; + + /** 因订单已终态、存在在途售后或任务已产生尝试而未同步的订单数量。 */ + @Schema(description = "跳过订单数量") + private Integer skippedOrderCount; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java index fa5149d9f..450594a7e 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java @@ -10,10 +10,12 @@ import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto; import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity; import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity; import com.bsd.pjcloud.hard.api.vo.order.HardAutoRefundTaskVo; +import com.bsd.pjcloud.hard.api.vo.order.HardAutoVerifyRescanResultVo; import com.bsd.pjcloud.hard.api.vo.order.HardExpiredRefundRescanResultVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppAutoRefundTaskVo; import com.bsd.pjcloud.hard.service.HardAfterSaleService; import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService; +import com.bsd.pjcloud.hard.service.HardAutoVerifyService; import com.bsd.pjcloud.wallet.api.dto.MchManageDto; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; @@ -49,6 +51,11 @@ public class HardAutoRefundTaskController { */ private final HardAutoRefundTaskService taskService; + /** + * 自动核销服务,负责商户维度的历史订单重扫补建任务。 + */ + private final HardAutoVerifyService hardAutoVerifyService; + /** * 售后服务,用于 App 查询任务前复用订单查看授权。 */ @@ -126,6 +133,38 @@ public class HardAutoRefundTaskController { return R.ok(true); } + /** + * 按商户详情页传入的精确商户 ID 查询自动核销候选任务:包含尚未到期任务,订单仍处于待核销状态。 + * + * @param page 分页参数 + * @param dto 候选查询条件,其中商户 ID 必填 + * @return 当前商户的待自动核销订单分页 + */ + @GetMapping("/auto-verify-candidates") + @Operation(summary = "查询待自动核销订单") + @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_view')") + public R> autoVerifyCandidates(@ParameterObject Page page, @ParameterObject HardAutoRefundTaskPageDto dto) { + validateMerchantScope(dto.getMchId()); + // 详情接口只能查询一个已校验商户,禁止客户端利用内部批量字段扩大查询范围。 + dto.setMchIds(null); + return R.ok(taskService.pageVerifyCandidates(page, dto)); + } + + /** + * 按商户当前自动核销配置立即重扫历史待核销订单;开关关闭时只同步暂停任务,开关开启时执行已到期任务。 + * + * @param mchId 商户详情页传入的精确商户 ID + * @return 本轮扫描、任务同步和跳过数量 + */ + @PostMapping("/auto-verify-rescan") + @Operation(summary = "立即重新扫描自动核销订单") + @Idempotent(expireTime = 5, info = "正在重新扫描,请勿重复操作") + @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')") + public R autoVerifyRescan(@RequestParam Long mchId) { + validateMerchantScope(mchId); + return R.ok(hardAutoVerifyService.rescanAutoVerifyOrders(mchId, SecurityUtils.getUser().getId())); + } + /** * 查询指定任务全部只读尝试日志。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java index 56e69579a..3f4481cd9 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java @@ -67,6 +67,30 @@ public interface HardAutoRefundTaskMapper extends PjcloudBaseMapper pageVerifyCandidates(Page page, + @Param("dto") HardAutoRefundTaskPageDto dto, + @Param("now") LocalDateTime now); + + /** + * 查询指定商户仍处于已支付未核销、支付时间早于截止时间且没有处理中售后的每日菜谱钱包订单, + * 供自动核销立即重扫补建任务。部分退款完成的订单保留资格,不在此排除。 + * + * @param mchId 精确商户 ID + * @param payTimeDeadline 支付时间截止点,通常为当前时间减去配置天数 + * @return 按订单主键升序排列的可重扫订单 + */ + List listAutoVerifyRescanOrders(@Param("mchId") Long mchId, + @Param("payTimeDeadline") LocalDateTime payTimeDeadline); + /** * 查询指定商户仍处于已支付未核销状态且没有冲突售后的每日菜谱钱包订单,供立即重扫补建任务。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java index 3c5695af3..6e6e0fa00 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java @@ -36,6 +36,14 @@ public interface HardAutoRefundTaskService extends IService pageExpiredCandidates(Page page, HardAutoRefundTaskPageDto dto); + /** + * 分页查询商户的自动核销候选任务(含待执行、等待重试和暂停状态),供管理端跟踪核销进度。 + * + * @param page 分页参数 + * @param dto 服务端已注入商户数据范围的筛选条件 + * @return 自动核销任务分页 + */ + Page pageVerifyCandidates(Page page, HardAutoRefundTaskPageDto dto); + /** * 按商户当前过期预约配置重扫历史已支付未核销订单,补建或刷新任务并立即执行已到期任务。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoVerifyService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoVerifyService.java new file mode 100644 index 000000000..01c61f5f1 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoVerifyService.java @@ -0,0 +1,30 @@ +package com.bsd.pjcloud.hard.service; + +import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity; +import com.bsd.pjcloud.hard.api.vo.order.HardAutoVerifyRescanResultVo; + +/** + * 过期未核销自动核销服务。负责任务尝试的业务执行(静默核销与可选退库存)和历史订单重扫补建任务; + * 任务状态机、调度扫描和重试仍由 {@link HardAutoRefundTaskService} 统一驱动。 + */ +public interface HardAutoVerifyService { + + /** + * 在任务状态机事务内执行一次自动核销:复用人工核销的订单行锁、履约校验、二维码消费和状态条件更新, + * 核销方式标记为自动;任务快照要求退库存时按剩余有效数量回补每日菜谱线上池。 + * 本方法不创建售后单、不发生资金动作、不发推送与打印任务。 + * + * @param task 已锁定且通过配置与订单预检的自动核销任务 + */ + void completeAutoVerify(HardAutoRefundTaskEntity task); + + /** + * 按商户当前自动核销配置重扫历史已支付未核销订单,补建或刷新未尝试任务; + * 已到期任务立即进入核销状态机执行。部分退款完成的订单仍可自动核销剩余商品。 + * + * @param mchId 精确商户 ID + * @param operatorUserId 发起重扫的后台用户 ID,用于核销尝试审计 + * @return 本轮扫描、任务同步和跳过数量 + */ + HardAutoVerifyRescanResultVo rescanAutoVerifyOrders(Long mchId, Long operatorUserId); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java index 9724fb02c..68a4a1927 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java @@ -318,6 +318,16 @@ public interface HardOrderService extends IService { */ Boolean handCancelCode(HandCancelCodeDto dto); + /** + * 自动核销任务专用核销入口:复用人工核销的订单行锁、履约校验、二维码消费与状态条件更新, + * 核销方式标记为自动核销;整个过程静默执行,不发取餐广播、不建兑换券打印任务、不发核销成功推送。 + * 只应由自动核销任务在支付后超期未核销时调用,不对外部接口暴露。 + * + * @param orderId 待自动核销的每日菜谱预约订单主键 + * @return 核销完成时返回 {@code true} + */ + Boolean autoVerifyReserveOrder(Long orderId); + /** * 在同一个本地事务中核销一组预约订单,并在全部成功后创建一个合并兑换券任务。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java index bb4b0c7cf..a4520feb1 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java @@ -19,6 +19,7 @@ import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundAttemptResultEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskTypeEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum; +import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardSaleStatusEnum; import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto; import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity; @@ -33,6 +34,7 @@ import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundAttemptLogMapper; import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundTaskMapper; import com.bsd.pjcloud.hard.service.HardAfterSaleService; import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService; +import com.bsd.pjcloud.hard.service.HardAutoVerifyService; import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity; import com.bsd.pjcloud.wallet.api.feign.RemoteMchAutoRefundConfigService; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; @@ -125,6 +127,9 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl hardAfterSaleServiceProvider; + /** + * 自动核销服务延迟提供器,用于打破任务状态机、核销服务与订单服务之间的循环依赖。 + */ + private final ObjectProvider hardAutoVerifyServiceProvider; + /** * Wallet 自动退款配置远程服务,用于受理和每次尝试前复核当前开关。 */ @@ -252,6 +262,43 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl() + .eq(HardAutoRefundTaskEntity::getOrderId, order.getId()) + .eq(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())); + if (existing != null) { + return existing; + } + HardAutoRefundTaskEntity task = HardAutoRefundTaskEntity.builder() + .taskType(HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType()) + .orderId(order.getId()).mchId(order.getMerchantId()) + .configId(order.getAutoVerifyConfigId()) + .triggerTime(order.getAutoVerifyDeadline()) + .restoreInventory(order.getAutoVerifyRestoreInventory()) + .status(HardAutoRefundTaskStatusEnum.PENDING.getStatus()) + .autoAttemptCount(0).totalAttemptCount(0) + .nextRetryTime(order.getAutoVerifyDeadline()) + .build(); + if (baseMapper.insert(task) != 1) { + throw new RuntimeException("创建自动核销任务失败"); + } + return task; + } + /** * 事务提交成功后在当前请求线程同步执行首次退款,执行失败只更新任务,不改变创建接口返回值。 * @@ -410,6 +457,41 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl= MAX_AUTO_ATTEMPTS) { + task.setStatus(HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus()); + task.setNextRetryTime(null); + baseMapper.updateById(task); + return; + } + completeAttempt(task, triggerType, operatorUserId, true); + return; + } if (!matchesSnapshot(task.getConfigId(), task.getRuleType(), current)) { if (!recoverPersistedWalletRefund(task, triggerType, operatorUserId)) { pauseConfigUnavailable(task, "自动退款配置与订单快照不一致"); @@ -475,6 +557,9 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl pageVerifyCandidates(Page page, HardAutoRefundTaskPageDto dto) { + LocalDateTime now = LocalDateTime.now(); + Page entityPage = baseMapper.pageVerifyCandidates(page, dto, now); + List records = BeanUtils.toBean(entityPage.getRecords(), HardAutoRefundTaskVo.class); + fillOrderData(records); + fillUserData(records); + fillMerchantData(records); + Page result = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); + result.setRecords(records); + return result; + } + /** * 按商户当前过期预约配置重扫历史订单,补建或刷新未尝试任务,并立即执行已经到期的任务。 * 资金动作仍统一经过现有订单锁、售后校验、Wallet 幂等和自动重试状态机;开关关闭时只同步暂停任务,绝不调用退款。 @@ -1289,18 +1477,23 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl() .and(wrapper -> wrapper.eq(HardAutoRefundTaskEntity::getStatus, HardAutoRefundTaskStatusEnum.PENDING.getStatus()) - .and(nested -> nested.ne(HardAutoRefundTaskEntity::getTaskType, - HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) - .or(expired -> expired.eq(HardAutoRefundTaskEntity::getTaskType, - HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) + // 售后任务无触发时间即扫;过期退款与自动核销任务必须到达快照触发时间后才可执行。 + .and(nested -> nested.notIn(HardAutoRefundTaskEntity::getTaskType, + HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType(), + HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType()) + .or(triggered -> triggered.in(HardAutoRefundTaskEntity::getTaskType, + HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType(), + HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType()) .le(HardAutoRefundTaskEntity::getTriggerTime, now))) .or(nested -> nested.eq(HardAutoRefundTaskEntity::getStatus, HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus()) .le(HardAutoRefundTaskEntity::getNextRetryTime, now) - .and(expired -> expired.ne(HardAutoRefundTaskEntity::getTaskType, - HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) - .or(expiredType -> expiredType.eq(HardAutoRefundTaskEntity::getTaskType, - HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) + .and(triggered -> triggered.notIn(HardAutoRefundTaskEntity::getTaskType, + HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType(), + HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType()) + .or(triggeredType -> triggeredType.in(HardAutoRefundTaskEntity::getTaskType, + HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType(), + HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType()) .le(HardAutoRefundTaskEntity::getTriggerTime, now))))) .orderByAsc(HardAutoRefundTaskEntity::getNextRetryTime) .orderByAsc(HardAutoRefundTaskEntity::getId) @@ -1331,6 +1524,10 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl cars = hardCarService.getByOrderId(orderId); + if (ObjectUtil.isEmpty(cars)) { + throw new RuntimeException("自动核销订单缺少购物车,无法恢复库存"); + } + List restoreCars = cars.stream() + .filter(car -> car != null && car.getProductId() != null) + .map(car -> HardCarEntity.builder() + .productId(car.getProductId()) + .productCount(resolveEffectiveProductCount(car)) + .build()) + .filter(car -> car.getProductCount() != null && car.getProductCount() > 0) + .toList(); + if (restoreCars.isEmpty()) { + throw new RuntimeException("自动核销订单没有可恢复库存的商品"); + } + String inventoryBizId = "HARD_AUTO_VERIFY_ORDER:".concat(String.valueOf(orderId)); + hardOrderService.fastenerInventory(order, restoreCars, YesNoEnum.NO, null, inventoryBizId); + } + + /** + * 解析购物车行的剩余有效数量;历史数据缺少退款统计字段时回退为原始购买数量。 + * + * @param car 订单购物车展示对象 + * @return 扣除已完成退款后的剩余份数 + */ + private int resolveEffectiveProductCount(HardCarVo car) { + Integer count = car.getEffectiveProductCount() == null ? car.getProductCount() : car.getEffectiveProductCount(); + return count == null ? 0 : Math.max(0, count); + } + + /** + * 按商户当前自动核销配置重扫历史订单,补建或刷新未尝试任务;已到期任务立即进入核销状态机。 + * 与过期退款不同:部分退款完成的订单仍可自动核销剩余商品,因此只排除处理中售后,不排除已完成售后。 + * + * @param mchId 精确商户 ID + * @param operatorUserId 发起重扫的后台用户 ID,用于核销尝试审计 + * @return 本轮扫描、任务同步和跳过数量 + */ + @Override + public HardAutoVerifyRescanResultVo rescanAutoVerifyOrders(Long mchId, Long operatorUserId) { + if (mchId == null) { + throw new RuntimeException("商户不能为空"); + } + MchAutoRefundConfigVo config = loadCurrentConfig(mchId); + if (config == null || config.getId() == null || !Objects.equals(config.getMchId(), mchId) + || !Objects.equals(config.getOrderType(), SUPPORTED_ORDER_TYPE)) { + throw new RuntimeException("当前商户未配置自动核销规则"); + } + int verifyDays = config.getAutoVerifyDays() == null ? 0 : config.getAutoVerifyDays(); + if (verifyDays < MIN_AUTO_VERIFY_DAYS || verifyDays > MAX_AUTO_VERIFY_DAYS) { + throw new RuntimeException("支付后自动核销天数必须在1到365天之间"); + } + boolean enabled = Objects.equals(config.getAutoVerifyEnabled(), YesNoEnum.YES.getCode()); + String restoreInventory = Objects.equals(config.getAutoVerifyRestoreInventory(), YesNoEnum.YES.getCode()) + ? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode(); + // 只有支付时间已超过配置天数的订单才具备重扫意义,其余订单由支付时创建的任务接管。 + LocalDateTime payTimeDeadline = LocalDateTime.now().minusDays(verifyDays); + List orders = autoRefundTaskMapper.listAutoVerifyRescanOrders(mchId, payTimeDeadline); + HardAutoVerifyRescanResultVo result = new HardAutoVerifyRescanResultVo(); + result.setScannedOrderCount(orders == null ? 0 : orders.size()); + result.setCreatedTaskCount(0); + result.setRefreshedTaskCount(0); + result.setSkippedOrderCount(0); + if (orders == null || orders.isEmpty()) { + return result; + } + + LocalDateTime now = LocalDateTime.now(); + for (HardOrderEntity order : orders) { + boolean taskSynced = false; + try { + if (order.getPayTime() == null) { + result.setSkippedOrderCount(result.getSkippedOrderCount() + 1); + log.warn("{}跳过缺少支付时间的订单,orderId:{}", TAG, order.getId()); + continue; + } + LocalDateTime triggerTime = order.getPayTime().plusDays(verifyDays); + LockInfo syncLock = lockTemplate.lock(buildOrderLockKey(order.getId()), + ORDER_LOCK_EXPIRE_MILLIS, ORDER_LOCK_WAIT_MILLIS); + if (syncLock == null) { + result.setSkippedOrderCount(result.getSkippedOrderCount() + 1); + continue; + } + int syncResult; + try { + syncResult = getSelf().syncAutoVerifyRescanTask(order, config, triggerTime, restoreInventory, enabled); + } finally { + try { + lockTemplate.releaseLock(syncLock); + } catch (Exception exception) { + log.warn("{}释放重扫订单锁失败,orderId:{},exceptionType:{}", TAG, order.getId(), + exception.getClass().getSimpleName()); + } + } + if (syncResult == RESCAN_SYNC_CREATED) { + result.setCreatedTaskCount(result.getCreatedTaskCount() + 1); + } else if (syncResult == RESCAN_SYNC_REFRESHED) { + result.setRefreshedTaskCount(result.getRefreshedTaskCount() + 1); + } else { + result.setSkippedOrderCount(result.getSkippedOrderCount() + 1); + continue; + } + taskSynced = true; + if (enabled && !triggerTime.isAfter(now)) { + HardAutoRefundTaskEntity task = autoRefundTaskMapper.selectOne(new LambdaQueryWrapper() + .eq(HardAutoRefundTaskEntity::getOrderId, order.getId()) + .eq(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())); + if (task != null && List.of(HardAutoRefundTaskStatusEnum.PENDING.getStatus(), + HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus()).contains(task.getStatus())) { + hardAutoRefundTaskService.execute(task.getId(), HardAutoRefundTriggerTypeEnum.EXPIRED_RESCAN, + operatorUserId); + } + } + } catch (Exception exception) { + if (!taskSynced) { + result.setSkippedOrderCount(result.getSkippedOrderCount() + 1); + } + log.warn("{}重扫订单失败,orderId:{},exceptionType:{}", TAG, order.getId(), + exception.getClass().getSimpleName()); + } + } + return result; + } + + /** + * 同步单个历史订单的自动核销任务。已成功、已跳过或已产生尝试的任务不允许被新配置覆盖, + * 避免重扫把已经核销或人工介入过的订单再次排队;部分退款完成的订单保留核销资格。 + * + * @param order 历史已支付未核销订单 + * @param config 当前商户自动核销配置 + * @param triggerTime 按当前天数以订单支付时间重新计算的触发时间 + * @param restoreInventory 当前退库存快照 + * @param enabled 当前自动核销开关 + * @return 新建、刷新或跳过结果编码 + */ + @Transactional(rollbackFor = Exception.class) + public int syncAutoVerifyRescanTask(HardOrderEntity order, MchAutoRefundConfigVo config, + LocalDateTime triggerTime, String restoreInventory, boolean enabled) { + if (order == null || order.getId() == null || config == null || config.getId() == null || triggerTime == null) { + return RESCAN_SYNC_SKIPPED; + } + HardOrderEntity currentOrder = hardOrderMapper.selectByIdForUpdate(order.getId()); + if (currentOrder == null || !Objects.equals(currentOrder.getMerchantId(), config.getMchId()) + || !Objects.equals(currentOrder.getOrderType(), HardOrderTypeEnum.RESERVE.getType()) + || !Objects.equals(currentOrder.getOrderStatus(), HardOrderStatusEnum.PAY.getType()) + || !Objects.equals(currentOrder.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType()) + || currentOrder.getPayTime() == null) { + return RESCAN_SYNC_SKIPPED; + } + // 只排除处理中售后;已完成的部分退款不剥夺剩余商品的自动核销资格。 + Long pendingAfterSaleCount = afterSaleMapper.selectCount(new LambdaQueryWrapper() + .eq(HardAfterSaleEntity::getOrderId, currentOrder.getId()) + .eq(HardAfterSaleEntity::getStatus, HardSaleStatusEnum.APPLY.getStatus())); + if (pendingAfterSaleCount != null && pendingAfterSaleCount > 0) { + return RESCAN_SYNC_SKIPPED; + } + HardAutoRefundTaskEntity existing = autoRefundTaskMapper.selectOne(new LambdaQueryWrapper() + .eq(HardAutoRefundTaskEntity::getOrderId, currentOrder.getId()) + .eq(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())); + if (existing != null) { + boolean refreshableStatus = List.of(HardAutoRefundTaskStatusEnum.PENDING.getStatus(), + HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus(), + HardAutoRefundTaskStatusEnum.CONFIG_UNAVAILABLE.getStatus(), + HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus()).contains(existing.getStatus()); + if (!refreshableStatus || !Objects.equals(existing.getMchId(), currentOrder.getMerchantId()) + || existing.getAutoAttemptCount() != null && existing.getAutoAttemptCount() > 0) { + return RESCAN_SYNC_SKIPPED; + } + existing.setConfigId(config.getId()); + existing.setTriggerTime(triggerTime); + existing.setRestoreInventory(restoreInventory); + existing.setStatus(enabled ? HardAutoRefundTaskStatusEnum.PENDING.getStatus() + : HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus()); + existing.setNextRetryTime(enabled ? triggerTime : null); + existing.setLastError(null); + if (autoRefundTaskMapper.updateById(existing) != 1) { + throw new RuntimeException("刷新自动核销任务失败"); + } + refreshOrderAutoVerifySnapshot(currentOrder.getId(), config.getId(), triggerTime, restoreInventory); + return RESCAN_SYNC_REFRESHED; + } + HardAutoRefundTaskEntity task = HardAutoRefundTaskEntity.builder() + .taskType(HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType()) + .orderId(currentOrder.getId()).mchId(currentOrder.getMerchantId()) + .configId(config.getId()).triggerTime(triggerTime) + .restoreInventory(restoreInventory) + .status(enabled ? HardAutoRefundTaskStatusEnum.PENDING.getStatus() + : HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus()) + .autoAttemptCount(0).totalAttemptCount(0) + .nextRetryTime(enabled ? triggerTime : null) + .build(); + if (autoRefundTaskMapper.insert(task) != 1) { + throw new RuntimeException("创建自动核销任务失败"); + } + refreshOrderAutoVerifySnapshot(currentOrder.getId(), config.getId(), triggerTime, restoreInventory); + return RESCAN_SYNC_CREATED; + } + + /** + * 重扫补建或刷新任务时同步写入订单自动核销快照,保持订单数据与任务触发口径一致; + * 已存在更新快照的订单也会被覆盖为当前配置,因为任务尚未产生任何尝试。 + * + * @param orderId 订单 ID + * @param configId 当前配置 ID + * @param triggerTime 重算后的触发时间 + * @param restoreInventory 当前退库存快照 + */ + private void refreshOrderAutoVerifySnapshot(Long orderId, Long configId, LocalDateTime triggerTime, + String restoreInventory) { + HardOrderEntity orderUpdate = HardOrderEntity.builder() + .id(orderId) + .autoVerifyConfigId(configId) + .autoVerifyDeadline(triggerTime) + .autoVerifyRestoreInventory(restoreInventory) + .build(); + if (hardOrderMapper.updateById(orderUpdate) != 1) { + throw new RuntimeException("刷新自动核销订单快照失败"); + } + } + + /** + * 查询商户当前自动核销配置;配置服务异常按未配置处理,由调用方提示业务错误。 + * + * @param mchId 商户 ID + * @return 当前配置;未配置或响应异常时返回 {@code null} + */ + private MchAutoRefundConfigVo loadCurrentConfig(Long mchId) { + R result; + try { + result = remoteConfigService.getExact(mchId, SUPPORTED_ORDER_TYPE); + } catch (Exception exception) { + log.warn("{}查询配置失败,mchId:{},exceptionType:{}", TAG, mchId, exception.getClass().getSimpleName()); + return null; + } + if (result == null || !result.isOk()) { + return null; + } + return result.getData(); + } + + /** + * 构造与退款、售后共用的订单 Redis 锁键,保证自动核销重扫与任务执行、人工操作互斥。 + * + * @param orderId 订单 ID + * @return 订单级分布式锁键 + */ + private String buildOrderLockKey(Long orderId) { + return ORDER_LOCK_PREFIX.concat(String.valueOf(orderId)); + } + + /** + * 获取当前 Spring 代理,保证类内调用的事务方法经过事务拦截器。 + * + * @return 当前核销服务代理 + */ + private HardAutoVerifyServiceImpl getSelf() { + return SpringUtil.getBean(getClass()); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java index 0e4ea7023..b1b791bd4 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java @@ -56,6 +56,7 @@ import com.bsd.pjcloud.hard.api.constant.HardThirdMiStatusEnum; import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; 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.HardOrderVerificationTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum; import com.bsd.pjcloud.hard.api.constant.xfj.HardXfjTypeEnum; @@ -191,6 +192,12 @@ public class HardOrderServiceImpl extends ServiceImpl= MIN_AUTO_VERIFY_DAYS + && autoRefundConfig.getAutoVerifyDays() <= MAX_AUTO_VERIFY_DAYS + && autoRefundConfig.getId() != null) { + autoVerifyConfigId = autoRefundConfig.getId(); + autoVerifyDeadline = payTime.plusDays(autoRefundConfig.getAutoVerifyDays()); + autoVerifyRestoreInventory = Objects.equals(autoRefundConfig.getAutoVerifyRestoreInventory(), + YesNoEnum.YES.getCode()) ? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode(); + } HardOrderEntity orderUpdate = HardOrderEntity.builder() .id(order.getId()) .payTime(payTime) @@ -2716,6 +2740,9 @@ public class HardOrderServiceImpl extends ServiceImpl() @@ -3983,7 +4055,10 @@ public class HardOrderServiceImpl extends ServiceImpl result = remoteCodeUseService.cancelCode(id); + if (ObjectUtil.isEmpty(result) || result.getCode() != 0 || !result.getData()) { + throw new I18nBizException(ErrorCodes.HARD_ACT_QR_BAD); + } + } + /** * 原子批量核销超市、食堂预约订单。 * @@ -4054,12 +4150,14 @@ public class HardOrderServiceImpl extends ServiceImpl() .eq(HardOrderEntity::getId, order.getId()) .eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.PAY.getType())); if (updated == 0) throw new I18nBizException(ErrorCodes.HARD_VERIFY_STATUS_CHANGED); order.setVerificationTime(verificationTime); + order.setVerificationType(HardOrderVerificationTypeEnum.MANUAL.getType()); order.setHardId(sourceHardId); order.setOrderStatus(HardOrderStatusEnum.CANCEL.getType()); if (ObjectUtil.equals(order.getSysType(), HardTypeEnum.CANTEEN.getType())) { @@ -4251,6 +4349,7 @@ public class HardOrderServiceImpl extends ServiceImpl() .set(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.PAY.getType()) .set(HardOrderEntity::getVerificationTime, null) + .set(HardOrderEntity::getVerificationType, null) .set(HardOrderEntity::getHardId, null) .set(HardOrderEntity::getAndroidOrderCode, null) .eq(HardOrderEntity::getId, order.getId()) @@ -4284,6 +4383,10 @@ public class HardOrderServiceImpl extends ServiceImpl refundCarList, YesNoEnum isReduce, Long afterSaleId, String inventoryBizId, boolean validateOnly) { - if (ObjectUtil.equals(isReduce, YesNoEnum.NO) && afterSaleId == null) { + // 恢复库存必须携带幂等隔离键:售后场景用售后单 ID,自动核销等非售后场景由调用方传入稳定业务号。 + if (ObjectUtil.equals(isReduce, YesNoEnum.NO) && afterSaleId == null && StrUtil.isBlank(inventoryBizId)) { throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_AFTERSALE_MISSING); } List carList = hardCarService.getByParentCarId(order.getParentCarId()); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAutoRefundTaskMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAutoRefundTaskMapper.xml index 9269d825f..e5a1ab976 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAutoRefundTaskMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAutoRefundTaskMapper.xml @@ -102,4 +102,94 @@ ) order by o.id asc + + + + + + 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 ec63ae1d5..5af00bd24 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 @@ -40,6 +40,9 @@ a.expired_refund_usage_end_time, a.expired_refund_deadline, a.expired_refund_restore_inventory, + a.auto_verify_config_id, + a.auto_verify_deadline, + a.auto_verify_restore_inventory, a.sys_type, a.total_price, a.extra_price, @@ -51,6 +54,7 @@ a.refund_time, a.refund_status, a.verification_time, + a.verification_type, a.apply_cancel_time, a.unfollow_time, a.android_order_code, diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java index 4c5a34135..fd960980d 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java @@ -10,6 +10,7 @@ import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskTypeEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum; +import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum; import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity; import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; @@ -18,6 +19,7 @@ import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundAttemptLogMapper; import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundTaskMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.HardAfterSaleService; +import com.bsd.pjcloud.hard.service.HardAutoVerifyService; import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity; import com.bsd.pjcloud.wallet.api.feign.RemoteMchAutoRefundConfigService; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; @@ -62,6 +64,9 @@ class HardAutoRefundTaskServiceImplTest { /** 售后服务延迟提供器 Mock。 */ @Mock private ObjectProvider afterSaleServiceProvider; + /** 自动核销服务延迟提供器 Mock。 */ + @Mock + private ObjectProvider autoVerifyServiceProvider; /** Wallet 自动退款配置远程服务 Mock。 */ @Mock private RemoteMchAutoRefundConfigService configService; @@ -92,7 +97,8 @@ class HardAutoRefundTaskServiceImplTest { @BeforeEach void setUp() { service = new HardAutoRefundTaskServiceImpl(attemptLogMapper, afterSaleMapper, orderMapper, - afterSaleServiceProvider, configService, walletRechargeService, mchService, userService, lockTemplate); + afterSaleServiceProvider, autoVerifyServiceProvider, configService, walletRechargeService, + mchService, userService, lockTemplate); ReflectionTestUtils.setField(service, "baseMapper", taskMapper); lenient().when(attemptLogMapper.insert(any(HardAutoRefundAttemptLogEntity.class))).thenReturn(1); lenient().when(taskMapper.updateById(any(HardAutoRefundTaskEntity.class))).thenReturn(1); @@ -279,6 +285,129 @@ class HardAutoRefundTaskServiceImplTest { .autoAttemptCount(autoAttempts).totalAttemptCount(totalAttempts).build(); } + /** 订单已人工核销或退款时,自动核销任务应直接进入跳过终态并写跳过日志。 */ + @Test + void autoVerifyTaskShouldSkipWhenOrderTerminal() { + HardAutoRefundTaskEntity task = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus()); + when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task); + when(configService.getExact(4L, "2")).thenReturn(R.ok(verifyConfig(true))); + when(orderMapper.selectByIdForUpdate(3L)).thenReturn(HardOrderEntity.builder() + .id(3L).orderStatus(HardOrderStatusEnum.CANCEL.getType()).build()); + + service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null); + + assertEquals(HardAutoRefundTaskStatusEnum.SKIPPED.getStatus(), task.getStatus()); + assertNull(task.getNextRetryTime()); + ArgumentCaptor captor = ArgumentCaptor.forClass(HardAutoRefundAttemptLogEntity.class); + verify(attemptLogMapper).insert(captor.capture()); + assertEquals("50", captor.getValue().getResultStatus()); + verify(autoVerifyServiceProvider, org.mockito.Mockito.never()).getObject(); + } + + /** 订单售后处理中时应延后 24 小时重试,且不消耗自动尝试额度、不调用核销服务。 */ + @Test + void autoVerifyTaskShouldDeferWhenRefundPending() { + HardAutoRefundTaskEntity task = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus()); + when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task); + when(configService.getExact(4L, "2")).thenReturn(R.ok(verifyConfig(true))); + when(orderMapper.selectByIdForUpdate(3L)).thenReturn(HardOrderEntity.builder() + .id(3L) + .orderStatus(HardOrderStatusEnum.PAY.getType()) + .refundStatus(HardRefundStatusEnum.APPLY.getStatus()) + .build()); + LocalDateTime before = LocalDateTime.now(); + + service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null); + + assertEquals(HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus(), task.getStatus()); + assertEquals(0, task.getAutoAttemptCount()); + assertTrue(!task.getNextRetryTime().isBefore(before.plusHours(24)) + && !task.getNextRetryTime().isAfter(LocalDateTime.now().plusHours(24))); + verify(autoVerifyServiceProvider, org.mockito.Mockito.never()).getObject(); + } + + /** 自动核销开关关闭时应沿用退款任务的暂停语义,任务保持开关关闭暂停等待重新开启。 */ + @Test + void autoVerifyTaskShouldPauseWhenSwitchDisabled() { + HardAutoRefundTaskEntity task = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus()); + when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task); + when(configService.getExact(4L, "2")).thenReturn(R.ok(verifyConfig(false))); + + service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null); + + assertEquals(HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus(), task.getStatus()); + verify(orderMapper, org.mockito.Mockito.never()).selectByIdForUpdate(any()); + } + + /** 触发时间未到或任务已跳过终态时,canExecute 都不允许自动核销任务进入执行。 */ + @Test + void autoVerifyTaskShouldRespectTriggerTimeAndSkippedStatus() { + HardAutoRefundTaskEntity futureTask = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus()); + futureTask.setTriggerTime(LocalDateTime.now().plusDays(1)); + Boolean futureAllowed = ReflectionTestUtils.invokeMethod(service, "canExecute", futureTask, + HardAutoRefundTriggerTypeEnum.SCHEDULED); + HardAutoRefundTaskEntity dueTask = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus()); + Boolean dueAllowed = ReflectionTestUtils.invokeMethod(service, "canExecute", dueTask, + HardAutoRefundTriggerTypeEnum.SCHEDULED); + HardAutoRefundTaskEntity skippedTask = verifyTask(HardAutoRefundTaskStatusEnum.SKIPPED.getStatus()); + Boolean skippedAllowed = ReflectionTestUtils.invokeMethod(service, "canExecute", skippedTask, + HardAutoRefundTriggerTypeEnum.SCHEDULED); + + assertEquals(Boolean.FALSE, futureAllowed); + assertEquals(Boolean.TRUE, dueAllowed); + assertEquals(Boolean.FALSE, skippedAllowed); + } + + /** 同一订单已存在自动核销任务时保持幂等复用,不再重复插入。 */ + @Test + void createAutoVerifyTaskShouldReuseExistingTask() { + HardAutoRefundTaskEntity existing = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus()); + when(taskMapper.selectOne(any())).thenReturn(existing); + HardOrderEntity order = HardOrderEntity.builder().id(3L).merchantId(4L) + .orderType(HardOrderTypeEnum.RESERVE.getType()) + .orderStatus(HardOrderStatusEnum.PAY.getType()) + .payType(HardPayTypeEnum.WALLET_DEFAULT.getType()) + .autoVerifyConfigId(5L) + .autoVerifyDeadline(LocalDateTime.now().plusDays(7)) + .build(); + + HardAutoRefundTaskEntity result = service.createAutoVerifyTask(order); + + assertEquals(existing, result); + verify(taskMapper, org.mockito.Mockito.never()).insert(any(HardAutoRefundTaskEntity.class)); + } + + /** + * 构造已到触发时间的自动核销任务。 + * + * @param status 当前任务状态 + * @return 可供执行分支使用的任务 + */ + private HardAutoRefundTaskEntity verifyTask(String status) { + return HardAutoRefundTaskEntity.builder().id(1L).orderId(3L).mchId(4L).configId(5L) + .taskType(HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType()) + .triggerTime(LocalDateTime.now().minusMinutes(1)).status(status) + .restoreInventory(YesNoEnum.NO.getCode()) + .autoAttemptCount(0).totalAttemptCount(0).build(); + } + + /** + * 构造自动核销已开启且身份匹配的任务配置。 + * + * @param enabled 自动核销开关 + * @return 与任务快照一致的当前配置 + */ + private MchAutoRefundConfigVo verifyConfig(boolean enabled) { + MchAutoRefundConfigVo config = new MchAutoRefundConfigVo(); + config.setId(5L); + config.setMchId(4L); + config.setOrderType("2"); + config.setAutoVerifyEnabled(enabled ? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode()); + config.setAutoVerifyDays(7); + config.setAutoVerifyRestoreInventory(YesNoEnum.YES.getCode()); + return config; + } + /** * 构造已到期的每日菜谱过期退款任务。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoVerifyServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoVerifyServiceImplTest.java new file mode 100644 index 000000000..50c2b1262 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoVerifyServiceImplTest.java @@ -0,0 +1,201 @@ +package com.bsd.pjcloud.hard.service.impl.order; + +import com.baomidou.lock.LockTemplate; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum; +import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskTypeEnum; +import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; +import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; +import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleMapper; +import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundTaskMapper; +import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; +import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService; +import com.bsd.pjcloud.hard.service.HardCarService; +import com.bsd.pjcloud.hard.service.HardOrderService; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchAutoRefundConfigService; +import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 过期未核销自动核销执行与重扫配置测试:静默核销、按剩余数量退库存和非法配置拦截。 + */ +@ExtendWith(MockitoExtension.class) +class HardAutoVerifyServiceImplTest { + + /** 自动退款任务 Mapper Mock,重扫流程使用。 */ + @Mock + private HardAutoRefundTaskMapper autoRefundTaskMapper; + /** 订单 Mapper Mock。 */ + @Mock + private HardOrderMapper hardOrderMapper; + /** 售后 Mapper Mock。 */ + @Mock + private HardAfterSaleMapper afterSaleMapper; + /** 购物车服务 Mock,提供剩余有效份数。 */ + @Mock + private HardCarService hardCarService; + /** 订单服务 Mock,提供静默核销和库存恢复入口。 */ + @Mock + private HardOrderService hardOrderService; + /** 自动退款任务服务 Mock,重扫后驱动到期任务执行。 */ + @Mock + private HardAutoRefundTaskService hardAutoRefundTaskService; + /** Wallet 配置远程服务 Mock。 */ + @Mock + private RemoteMchAutoRefundConfigService remoteConfigService; + /** 订单分布式锁模板 Mock。 */ + @Mock + private LockTemplate lockTemplate; + /** 被测自动核销服务。 */ + private HardAutoVerifyServiceImpl service; + + /** + * 按构造器注入全部依赖。 + */ + @BeforeEach + void setUp() { + service = new HardAutoVerifyServiceImpl(autoRefundTaskMapper, hardOrderMapper, afterSaleMapper, + hardCarService, hardOrderService, hardAutoRefundTaskService, remoteConfigService, lockTemplate); + } + + /** 触发时间未到的任务不允许提前核销,防止调度竞态绕过商户配置的时限。 */ + @Test + void completeAutoVerifyShouldRejectFutureTrigger() { + HardAutoRefundTaskEntity task = task(YesNoEnum.NO.getCode(), LocalDateTime.now().plusDays(1)); + + RuntimeException exception = assertThrows(RuntimeException.class, () -> service.completeAutoVerify(task)); + + assertEquals("自动核销尚未到达触发时间", exception.getMessage()); + verify(hardOrderService, never()).autoVerifyReserveOrder(any()); + } + + /** 退库存开关开启时按剩余有效份数回补线上池:已退款份数不重复回补,幂等业务号按订单固定。 */ + @Test + void completeAutoVerifyShouldRestoreInventoryWithEffectiveCounts() { + HardAutoRefundTaskEntity task = task(YesNoEnum.YES.getCode(), LocalDateTime.now().minusMinutes(1)); + HardOrderEntity order = HardOrderEntity.builder().id(1L).build(); + when(hardOrderMapper.selectById(1L)).thenReturn(order); + HardCarVo partiallyRefunded = new HardCarVo(); + partiallyRefunded.setProductId(11L); + partiallyRefunded.setProductCount(3); + partiallyRefunded.setEffectiveProductCount(2); + HardCarVo fullyRefunded = new HardCarVo(); + fullyRefunded.setProductId(12L); + fullyRefunded.setProductCount(2); + fullyRefunded.setEffectiveProductCount(0); + when(hardCarService.getByOrderId(1L)).thenReturn(List.of(partiallyRefunded, fullyRefunded)); + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + + service.completeAutoVerify(task); + + verify(hardOrderService).autoVerifyReserveOrder(1L); + verify(hardOrderService).fastenerInventory(eq(order), captor.capture(), eq(YesNoEnum.NO), isNull(), + eq("HARD_AUTO_VERIFY_ORDER:1")); + assertEquals(1, captor.getValue().size()); + assertEquals(11L, captor.getValue().get(0).getProductId()); + assertEquals(2, captor.getValue().get(0).getProductCount()); + } + + /** 退库存开关关闭时只静默核销,不触发任何库存恢复。 */ + @Test + void completeAutoVerifyShouldSkipInventoryWhenDisabled() { + HardAutoRefundTaskEntity task = task(YesNoEnum.NO.getCode(), LocalDateTime.now().minusMinutes(1)); + + service.completeAutoVerify(task); + + verify(hardOrderService).autoVerifyReserveOrder(1L); + verify(hardOrderService, never()).fastenerInventory(any(), anyList(), any(), any(), anyString()); + } + + /** 订单商品全部退款后无可恢复库存,核销事务应失败回滚并交由任务重试与人工排查。 */ + @Test + void completeAutoVerifyShouldFailWhenNoRestorableProduct() { + HardAutoRefundTaskEntity task = task(YesNoEnum.YES.getCode(), LocalDateTime.now().minusMinutes(1)); + when(hardOrderMapper.selectById(1L)).thenReturn(HardOrderEntity.builder().id(1L).build()); + HardCarVo fullyRefunded = new HardCarVo(); + fullyRefunded.setProductId(12L); + fullyRefunded.setProductCount(2); + fullyRefunded.setEffectiveProductCount(0); + when(hardCarService.getByOrderId(1L)).thenReturn(List.of(fullyRefunded)); + + RuntimeException exception = assertThrows(RuntimeException.class, () -> service.completeAutoVerify(task)); + + assertEquals("自动核销订单没有可恢复库存的商品", exception.getMessage()); + } + + /** 天数配置非法时重扫直接拒绝,不触碰任何订单和任务。 */ + @Test + void rescanShouldRejectInvalidDays() { + when(remoteConfigService.getExact(4L, "2")).thenReturn(R.ok(config(0))); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> service.rescanAutoVerifyOrders(4L, 99L)); + + assertEquals("支付后自动核销天数必须在1到365天之间", exception.getMessage()); + } + + /** 商户未配置自动核销时重扫拒绝执行,提示先完成配置。 */ + @Test + void rescanShouldRejectMissingConfig() { + when(remoteConfigService.getExact(4L, "2")).thenReturn(null); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> service.rescanAutoVerifyOrders(4L, 99L)); + + assertEquals("当前商户未配置自动核销规则", exception.getMessage()); + } + + /** + * 构造已到触发时间的自动核销任务。 + * + * @param restoreInventory 退库存快照 + * @param triggerTime 触发时间 + * @return 待执行任务 + */ + private HardAutoRefundTaskEntity task(String restoreInventory, LocalDateTime triggerTime) { + return HardAutoRefundTaskEntity.builder().id(9L).orderId(1L).mchId(4L).configId(5L) + .taskType(HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType()) + .triggerTime(triggerTime).status(HardAutoRefundTaskStatusEnum.PENDING.getStatus()) + .restoreInventory(restoreInventory) + .autoAttemptCount(0).totalAttemptCount(0).build(); + } + + /** + * 构造商户自动核销配置。 + * + * @param days 支付后自动核销天数 + * @return 已开启开关的配置 + */ + private MchAutoRefundConfigVo config(int days) { + MchAutoRefundConfigVo config = new MchAutoRefundConfigVo(); + config.setId(5L); + config.setMchId(4L); + config.setOrderType("2"); + config.setAutoVerifyEnabled(YesNoEnum.YES.getCode()); + config.setAutoVerifyDays(days); + config.setAutoVerifyRestoreInventory(YesNoEnum.YES.getCode()); + return config; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java index 9bee161d5..b734522d5 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java @@ -8,6 +8,8 @@ import com.bsd.pjcloud.common.core.exception.ErrorCodes; import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; 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.HardOrderVerificationTypeEnum; +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.HandCancelCodeDto; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; @@ -24,6 +26,7 @@ import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.mockito.ArgumentCaptor; import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDate; @@ -256,6 +259,51 @@ class HardOrderServiceImplVerificationTest { assertNull(order.getExpiredRefundDeadline()); } + /** + * 自动核销入口静默完成核销:核销方式写自动、不查询用户、不建兑换券打印任务、不写取餐消息。 + */ + @Test + void autoVerifyReserveOrderSilentlyVerifiesWithAutoType() { + HardOrderEntity order = reservationOrder(HardRefundStatusEnum.NONE, HardOrderStatusEnum.PAY); + order.setPayType(HardPayTypeEnum.WALLET_DEFAULT.getType()); + HardCarVo remaining = new HardCarVo(); + remaining.setProductCount(2); + remaining.setEffectiveProductCount(2); + when(hardOrderMapper.selectByIdForUpdate(1L)).thenReturn(order); + when(hardCarService.getByOrderId(1L)).thenReturn(List.of(remaining)); + when(remoteCodeUseService.isCancelCode(1L)).thenReturn(R.ok(false)); + when(remoteCodeUseService.cancelCode(1L)).thenReturn(R.ok(true)); + when(hardOrderMapper.update(any(HardOrderEntity.class), any(Wrapper.class))).thenReturn(1); + ArgumentCaptor captor = ArgumentCaptor.forClass(HardOrderEntity.class); + + Boolean result = service.autoVerifyReserveOrder(1L); + + assertTrue(Boolean.TRUE.equals(result)); + verify(hardOrderMapper).update(captor.capture(), any(Wrapper.class)); + assertEquals(HardOrderStatusEnum.CANCEL.getType(), captor.getValue().getOrderStatus()); + assertEquals(HardOrderVerificationTypeEnum.AUTO.getType(), captor.getValue().getVerificationType()); + assertEquals(-1L, captor.getValue().getHardId()); + // 静默核销不得触发人工核销的附属动作:无兑换券打印任务、无取餐成功消息。 + verify(hardInvoiceService, never()).createRedemptionVoucherTask(any(), anyLong()); + verify(hardMessageOutboxMapper, never()).insert(any(HardMessageOutboxEntity.class)); + } + + /** + * 自动核销订单不允许退回核销前:任务已终态,回退会让订单永远滞留待核销状态。 + */ + @Test + void rollbackReserveVerificationRejectsAutoVerifiedOrder() { + HardOrderEntity order = reservationOrder(HardRefundStatusEnum.NONE, HardOrderStatusEnum.CANCEL); + order.setVerificationType(HardOrderVerificationTypeEnum.AUTO.getType()); + when(hardOrderMapper.selectByIdForUpdate(1L)).thenReturn(order); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> service.rollbackReserveVerification(1L)); + + assertEquals("自动核销订单不支持退回核销前状态", exception.getMessage()); + verify(remoteCodeUseService, never()).rollbackReservationCode(anyLong()); + } + /** * 构造旧消费机通用核销接口请求。 * diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigDto.java index fe838532f..e17d496d2 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigDto.java @@ -63,4 +63,16 @@ public class MchAutoRefundConfigDto { /** 过期预约退款是否恢复库存,1恢复、0不恢复。 */ @Schema(description = "过期预约退款是否退库存") private String expiredReserveRefundRestoreInventory; + + /** 过期未核销自动核销开关,1开启、0关闭。 */ + @Schema(description = "过期未核销自动核销开关") + private String autoVerifyEnabled; + + /** 支付后自动核销天数,单位天,允许1至365;开关开启时必填。 */ + @Schema(description = "支付后自动核销天数") + private Integer autoVerifyDays; + + /** 自动核销是否恢复每日菜谱线上池库存,1恢复、0不恢复。 */ + @Schema(description = "自动核销是否退库存") + private String autoVerifyRestoreInventory; } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchAutoRefundConfigEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchAutoRefundConfigEntity.java index 0dd6391a8..9913453ff 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchAutoRefundConfigEntity.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchAutoRefundConfigEntity.java @@ -79,6 +79,18 @@ public class MchAutoRefundConfigEntity extends Model @Schema(description = "过期预约退款是否退库存") private String expiredReserveRefundRestoreInventory; + /** 过期未核销自动核销开关,持久化值为1开启、0关闭;与两个自动退款开关相互独立。 */ + @Schema(description = "过期未核销自动核销开关") + private String autoVerifyEnabled; + + /** 支付后自动核销天数,允许1至365;开关开启时必填,快照到订单生成触发时间。 */ + @Schema(description = "支付后自动核销天数") + private Integer autoVerifyDays; + + /** 自动核销是否恢复每日菜谱线上池库存,1恢复、0不恢复;配置保存时快照到订单和任务。 */ + @Schema(description = "自动核销是否退库存") + private String autoVerifyRestoreInventory; + /** * 创建人账号,由公共字段填充器在新增时写入。 */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchAutoRefundConfigServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchAutoRefundConfigServiceImpl.java index 014aa9018..b80387dc1 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchAutoRefundConfigServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchAutoRefundConfigServiceImpl.java @@ -59,6 +59,15 @@ public class MchAutoRefundConfigServiceImpl extends ServiceImpl MAX_AUTO_VERIFY_DAYS)) { + throw new RuntimeException("支付后自动核销天数必须在1至365天之间"); + } + // 开启自动核销时天数必填,避免任务创建时无法计算触发时间。 + if (Objects.equals(autoVerifyEnabled, YesNoEnum.YES.getCode()) && autoVerifyDays == null) { + throw new RuntimeException("开启自动核销时必须配置支付后天数"); + } + if (autoVerifyRestoreInventory != null && !Objects.equals(autoVerifyRestoreInventory, YesNoEnum.YES.getCode()) + && !Objects.equals(autoVerifyRestoreInventory, YesNoEnum.NO.getCode())) { + throw new RuntimeException("自动核销退库存开关值无效"); + } } /** 解析过期退款延迟,兼容历史配置行新增字段为空的场景。 */ From 13ef4c1f8e4076444c561c245629e38e89b36aeb Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Mon, 14 Sep 2026 11:40:58 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat:=E4=BA=BA=E8=84=B8=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=B1=95=E7=A4=BA=E7=94=A8=E6=88=B7=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=EF=BC=8C=E7=94=A8=E6=88=B7=E8=BA=AB=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/20260914_add_mch_auto_verify.sql | 93 ++++++++++++++++++- .../pjcloud/admin/api/vo/SysUserFaceVo.java | 42 ++++++++- .../admin/service/SysUserIdentityService.java | 5 +- .../service/impl/SysUserFaceServiceImpl.java | 8 ++ .../impl/user/SysUserIdentityServiceImpl.java | 11 ++- .../resources/mapper/SysUserFaceMapper.xml | 6 +- 6 files changed, 151 insertions(+), 14 deletions(-) diff --git a/db/20260914_add_mch_auto_verify.sql b/db/20260914_add_mch_auto_verify.sql index f0eebea8a..588c81066 100644 --- a/db/20260914_add_mch_auto_verify.sql +++ b/db/20260914_add_mch_auto_verify.sql @@ -1,8 +1,30 @@ -- 每日菜谱预约订单过期未核销自动核销。 --- 只增加自动核销配置、订单快照与核销方式字段,并扩展任务场景生成列;不回填历史订单。 --- 执行前应确认目标字段/索引不存在;权限复用 wallet_mchManage_setting、hard_hardAfterSale_view、 --- hard_hardAfterSale_adminRefund,无需新增 sys_menu 记录。 +-- 前置条件:20260822_add_expired_reserve_refund.sql 与 20260823_add_hard_order_verification_time.sql +-- 的配置表、订单表部分应已执行(本脚本多处 AFTER 锚点依赖其列)。任务表部分按下方诊断分支执行, +-- 兼容 20260822 任务表语句在部分环境未成功落库(scene_biz_id 缺失)的情况。 +-- 执行方式:先跑【诊断】确认现状,再按块执行;已执行过的块直接跳过,任务表三个分支只选其一。 +-- 权限复用 wallet_mchManage_setting、hard_hardAfterSale_view、hard_hardAfterSale_adminRefund,无需新增 sys_menu 记录。 +-- 【诊断一】任务表现有列,决定执行分支三A/三B/三C: +-- task_type 与 scene_biz_id 都缺 → 三A;只缺 scene_biz_id → 三B;scene_biz_id 已存在 → 三C。 +SELECT COLUMN_NAME, COLUMN_TYPE, IFNULL(GENERATION_EXPRESSION, '') AS generation +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = 'pjcloudx_hardware' + AND TABLE_NAME = 'hard_auto_refund_task' + AND COLUMN_NAME IN ('task_type', 'trigger_time', 'restore_inventory', 'scene_biz_id') +ORDER BY ORDINAL_POSITION; + +-- 【诊断二】任务表现有索引:确认旧唯一键 uk_hard_auto_refund_task_after_sale 与 +-- uk_hard_auto_refund_task_scene_biz、idx_hard_auto_refund_task_scan_type 是否存在; +-- 分支三A/三B 中 DROP/ADD INDEX 行按此结果增删。 +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 = 'hard_auto_refund_task' +GROUP BY INDEX_NAME +ORDER BY INDEX_NAME; + +-- 一、Wallet 配置列。诊断发现 auto_verify_enabled 已存在时整块跳过(重复执行会报 Duplicate column)。 ALTER TABLE `pjcloudx_wallet`.`mch_auto_refund_config` ADD COLUMN `auto_verify_enabled` char(1) NOT NULL DEFAULT '0' COMMENT '过期未核销自动核销开关,0关闭,1开启' AFTER `expired_reserve_refund_restore_inventory`, @@ -11,6 +33,7 @@ ALTER TABLE `pjcloudx_wallet`.`mch_auto_refund_config` ADD COLUMN `auto_verify_restore_inventory` char(1) NOT NULL DEFAULT '1' COMMENT '自动核销是否恢复线上池库存,0不恢复,1恢复' AFTER `auto_verify_days`; +-- 二、订单快照与核销方式列。诊断发现 auto_verify_config_id 已存在时整块跳过。 ALTER TABLE `pjcloudx_hardware`.`hard_order` ADD COLUMN `auto_verify_config_id` bigint(20) DEFAULT NULL COMMENT '支付成功时命中的自动核销配置ID快照,为空表示不参与自动核销' AFTER `expired_refund_restore_inventory`, @@ -21,8 +44,60 @@ ALTER TABLE `pjcloudx_hardware`.`hard_order` ADD COLUMN `verification_type` char(1) DEFAULT NULL COMMENT '核销方式,1人工核销,2自动核销;历史已核销订单为空视为人工' AFTER `verification_time`; --- 自动核销任务复用 hard_auto_refund_task(任务场景30),按 order_id 承载场景唯一键: --- 生成列必须同步扩展,否则场景30会落到 after_sale_id 为空,导致一单一任务约束失效。 +-- 三、任务表。先执行下方重复检查,再按诊断一选择 A/B/C 其中一个分支执行。 + +-- 执行分支三A或三B前应确认本查询返回 0 行;发现重复时必须先人工核对任务数据,禁止自动清理。 +SELECT tenant_id, task_type, del_flag, + CASE WHEN task_type = '20' THEN order_id ELSE after_sale_id END AS scene_biz_id, + COUNT(*) AS duplicate_count +FROM pjcloudx_hardware.hard_auto_refund_task +GROUP BY tenant_id, task_type, del_flag, + CASE WHEN task_type = '20' THEN order_id ELSE after_sale_id END +HAVING COUNT(*) > 1; + +-- 三A:task_type 与 scene_biz_id 都不存在(20260822 任务表两条语句均未生效)。 +-- 两条语句必须按顺序执行;若诊断二显示 uk_hard_auto_refund_task_after_sale 不存在, +-- 删除第二条中的 DROP INDEX 行;显示 idx_hard_auto_refund_task_scan_type 已存在则删除对应 ADD KEY 行。 +ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` + MODIFY COLUMN `after_sale_id` bigint(20) DEFAULT NULL COMMENT '售后单ID,过期退款和自动核销任务为空', + MODIFY COLUMN `rule_type` varchar(32) DEFAULT NULL COMMENT '普通售后任务规则快照,过期预约任务为空', + MODIFY COLUMN `eligibility_deadline` datetime DEFAULT NULL COMMENT '普通售后免审资格截止时间,过期预约任务为空', + ADD COLUMN `task_type` char(10) NOT NULL DEFAULT '10' + COMMENT '任务场景,10用户免审售后,20过期预约退款,30过期未核销自动核销' AFTER `after_sale_id`, + ADD COLUMN `trigger_time` datetime DEFAULT NULL + COMMENT '过期退款和自动核销任务按订单快照触发时间,旧售后任务为空' AFTER `eligibility_deadline`, + ADD COLUMN `restore_inventory` char(1) DEFAULT NULL + COMMENT '任务退库存快照,0不恢复,1恢复' AFTER `trigger_time`; + +ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` + ADD COLUMN `scene_biz_id` bigint(20) + GENERATED ALWAYS AS (CASE WHEN `task_type` IN ('20', '30') THEN `order_id` ELSE `after_sale_id` END) STORED + COMMENT '任务场景业务唯一值,系统生成,禁止业务代码写入' AFTER `restore_inventory`, + DROP INDEX `uk_hard_auto_refund_task_after_sale`, + ADD UNIQUE KEY `uk_hard_auto_refund_task_scene_biz` + (`tenant_id`, `task_type`, `scene_biz_id`, `del_flag`), + ADD KEY `idx_hard_auto_refund_task_scan_type` + (`tenant_id`, `task_type`, `status`, `next_retry_time`, `del_flag`), + MODIFY COLUMN `status` char(10) NOT NULL + COMMENT '任务状态,10待执行,20等待重试,30开关关闭暂停,31配置不可用暂停,40重试耗尽,50成功,60订单已终态跳过'; + +-- 三B:task_type 已存在、仅 scene_biz_id 缺失(20260822 第二条任务表语句当时失败回滚,即 +-- "Unknown column 'scene_biz_id'" 报错场景)。索引行同样按诊断二结果增删。 +ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` + MODIFY COLUMN `task_type` char(10) NOT NULL DEFAULT '10' + COMMENT '任务场景,10用户免审售后,20过期预约退款,30过期未核销自动核销', + MODIFY COLUMN `status` char(10) NOT NULL + COMMENT '任务状态,10待执行,20等待重试,30开关关闭暂停,31配置不可用暂停,40重试耗尽,50成功,60订单已终态跳过', + ADD COLUMN `scene_biz_id` bigint(20) + GENERATED ALWAYS AS (CASE WHEN `task_type` IN ('20', '30') THEN `order_id` ELSE `after_sale_id` END) STORED + COMMENT '任务场景业务唯一值,系统生成,禁止业务代码写入' AFTER `restore_inventory`, + DROP INDEX `uk_hard_auto_refund_task_after_sale`, + ADD UNIQUE KEY `uk_hard_auto_refund_task_scene_biz` + (`tenant_id`, `task_type`, `scene_biz_id`, `del_flag`), + ADD KEY `idx_hard_auto_refund_task_scan_type` + (`tenant_id`, `task_type`, `status`, `next_retry_time`, `del_flag`); + +-- 三C:scene_biz_id 已存在(20260822 完整生效),仅扩展生成列表达式和注释。 ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` MODIFY COLUMN `task_type` char(10) NOT NULL DEFAULT '10' COMMENT '任务场景,10用户免审售后,20过期预约退款,30过期未核销自动核销', @@ -32,8 +107,16 @@ ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` GENERATED ALWAYS AS (CASE WHEN `task_type` IN ('20', '30') THEN `order_id` ELSE `after_sale_id` END) STORED COMMENT '任务场景业务唯一值,系统生成,禁止业务代码写入'; +-- 四、尝试日志注释更新。幂等,任何状态都可执行。 ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_attempt_log` MODIFY COLUMN `result_status` char(10) NOT NULL COMMENT '尝试结果,10执行中,20成功,30失败,40因开关关闭跳过,50订单已终态跳过', MODIFY COLUMN `after_sale_id` bigint(20) DEFAULT NULL COMMENT '售后单ID,过期退款和自动核销任务为空'; + +-- 【验收】生成列表达式应包含 '20' 和 '30',且配置表、订单表新列均已存在。 +SELECT COLUMN_NAME, IFNULL(GENERATION_EXPRESSION, '') AS generation +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = 'pjcloudx_hardware' + AND TABLE_NAME = 'hard_auto_refund_task' + AND COLUMN_NAME = 'scene_biz_id'; diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceVo.java index 98bed8b11..f892c68cd 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceVo.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceVo.java @@ -1,13 +1,18 @@ package com.bsd.pjcloud.admin.api.vo; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity; import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface; import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface; import lombok.Data; +import java.util.List; + @Data -public class SysUserFaceVo extends SysUserFaceEntity implements BaseUserInterface, BaseDeptInterface { +public class SysUserFaceVo extends SysUserFaceEntity implements BaseUserInterface, BaseDeptInterface, BaseUserIdentityInterface { /** * 人脸图片 @@ -23,6 +28,41 @@ public class SysUserFaceVo extends SysUserFaceEntity implements BaseUserInterfac private String userAvatar; + /** + * 用户标准姓名字段,供统一用户信息组件展示;与历史 username 字段并存。 + */ + private String name; + + /** + * 用户标准手机号字段,供统一用户信息与身份组件展示;与历史 userMobile 字段并存。 + */ + private String phone; + + /** + * 用户标准头像字段,供统一用户信息组件展示;与历史 userAvatar 字段并存。 + */ + private String avatar; + + /** + * 用户性别:0未知 1男 2女 + */ + private Integer gender; + + /** + * 用户身份协议编码集合;没有身份时为空集合或空值。 + */ + private List identityTypes; + + /** + * 学生身份扩展资料;没有学生身份时为空。 + */ + private SysUserStudentDto studentInfo; + + /** + * 老师身份扩展资料;没有老师身份时为空。 + */ + private SysUserTeacherDto teacherInfo; + /** * 部门相关 */ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserIdentityService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserIdentityService.java index 84defee78..cd5dde1a5 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserIdentityService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserIdentityService.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.service.IService; import com.bsd.pjcloud.admin.api.dto.UserDTO; import com.bsd.pjcloud.admin.api.entity.user.SysUserIdentityEntity; import com.bsd.pjcloud.admin.api.vo.UserVO; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface; import java.util.List; @@ -46,12 +47,12 @@ public interface SysUserIdentityService extends IService * * @param userVos 已查询到基础数据的用户展示对象列表 */ - void fillUserIdentityTypes(List userVos); + void fillUserIdentityTypes(List userVos); /** * 批量填充用户身份及对应扩展信息,供分页列表避免逐用户查询。 * * @param userVos 已查询到基础数据的用户展示对象列表 */ - void fillUserIdentities(List userVos); + void fillUserIdentities(List userVos); } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java index 0ee52782e..b7eac25c4 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java @@ -33,6 +33,7 @@ import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper; import com.bsd.pjcloud.admin.service.SysDeptService; import com.bsd.pjcloud.admin.service.SysRoleService; import com.bsd.pjcloud.admin.service.SysUserFaceService; +import com.bsd.pjcloud.admin.service.SysUserIdentityService; import com.bsd.pjcloud.admin.service.SysUserRelationService; import com.bsd.pjcloud.admin.service.SysUserService; import com.bsd.pjcloud.admin.utils.flow.FlowUtil; @@ -146,6 +147,11 @@ public class SysUserFaceServiceImpl extends ServiceImpl list = baseMapper.scopePageList(page, dto, dataScope); + // 批量补齐身份协议与学生、老师扩展资料,供管理端“身份信息”复合列展示。 + sysUserIdentityService.fillUserIdentities(list.getRecords()); // 审批流程 if (dto.getIsFindCheckUser()) { List processInstanceIds = list.getRecords().stream().map(SysUserFaceVo::getProcessId).filter(ObjectUtil::isNotEmpty).distinct().toList(); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java index bdc0cd2fa..5c48735c0 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java @@ -13,6 +13,7 @@ import com.bsd.pjcloud.admin.api.entity.user.SysUserIdentityEntity; import com.bsd.pjcloud.admin.api.entity.user.SysUserStudentEntity; import com.bsd.pjcloud.admin.api.entity.user.SysUserTeacherEntity; import com.bsd.pjcloud.admin.api.vo.UserVO; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface; import com.bsd.pjcloud.admin.mapper.SysUserMapper; import com.bsd.pjcloud.admin.mapper.user.SysUserIdentityMapper; import com.bsd.pjcloud.admin.service.SysUserIdentityService; @@ -187,13 +188,13 @@ public class SysUserIdentityServiceImpl extends ServiceImpl userVos) { + public void fillUserIdentityTypes(List userVos) { if (CollUtil.isEmpty(userVos)) { return; } List userIds = userVos.stream() .filter(userVo -> userVo != null && userVo.getUserId() != null) - .map(UserVO::getUserId) + .map(BaseUserIdentityInterface::getUserId) .distinct() .toList(); if (CollUtil.isEmpty(userIds)) { @@ -217,13 +218,13 @@ public class SysUserIdentityServiceImpl extends ServiceImpl userVos) { + public void fillUserIdentities(List userVos) { if (CollUtil.isEmpty(userVos)) { return; } List userIds = userVos.stream() .filter(userVo -> userVo != null && userVo.getUserId() != null) - .map(UserVO::getUserId) + .map(BaseUserIdentityInterface::getUserId) .distinct() .toList(); if (CollUtil.isEmpty(userIds)) { @@ -236,7 +237,7 @@ public class SysUserIdentityServiceImpl extends ServiceImpl teacherByUserId = sysUserTeacherService.listByIds(userIds).stream() .collect(Collectors.toMap(SysUserTeacherEntity::getUserId, Function.identity(), (first, ignored) -> first)); - for (UserVO userVo : userVos) { + for (BaseUserIdentityInterface userVo : userVos) { if (userVo == null || userVo.getUserId() == null) { continue; } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserFaceMapper.xml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserFaceMapper.xml index dfe61e962..dd0a7d125 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserFaceMapper.xml +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserFaceMapper.xml @@ -84,8 +84,12 @@ diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletRechargeExcelDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletRechargeExcelDto.java index 577256eb1..01877a0df 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletRechargeExcelDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletRechargeExcelDto.java @@ -14,6 +14,8 @@ import lombok.Data; import lombok.NoArgsConstructor; import lombok.experimental.Accessors; +import jakarta.validation.constraints.Size; + @Data @Builder @AllArgsConstructor @@ -51,6 +53,14 @@ public class WalletRechargeExcelDto { @ExcelDict(value = "wallet_transaction_tag", multiple = true) private String tag; + /** + * Excel 中填写的流水备注,导入落库后随审批通过写入钱包流水 remark;允许为空。 + */ + @ColumnWidth(100) + @Size(max = 256, message = "备注不能超过256个字符") + @ExcelProperty("备注") + private String remark; + /** * 导入校验后解析出的财务标签字典值,多个值使用英文逗号分隔,不直接读取 Excel。 */ diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletStudentRechargeExcelDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletStudentRechargeExcelDto.java index cc32db6fa..6d4072d02 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletStudentRechargeExcelDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletStudentRechargeExcelDto.java @@ -10,6 +10,8 @@ import com.bsd.pjcloud.common.excel.annotation.ExcelDict; import com.bsd.pjcloud.common.excel.annotation.ExcelLine; import lombok.Data; +import jakarta.validation.constraints.Size; + /** * 学生充值/扣款申请 Excel 导入行。 *

使用学校和学号精确定位学生,再校验学生姓名,避免同名学生被错误充值或扣款。

@@ -52,6 +54,12 @@ public class WalletStudentRechargeExcelDto { @ExcelDict(value = "wallet_transaction_tag", multiple = true) private String tag; + /** Excel 中填写的流水备注,导入落库后随审批通过写入钱包流水 remark;允许为空。 */ + @ColumnWidth(100) + @Size(max = 256, message = "备注不能超过256个字符") + @ExcelProperty("备注") + private String remark; + /** 校验阶段解析出的财务标签字典值,多个值使用英文逗号分隔。 */ @ExcelIgnore private String tags; diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeImportEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeImportEntity.java index d006d8dd1..b2c1d615a 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeImportEntity.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeImportEntity.java @@ -73,6 +73,12 @@ public class WalletRechargeImportEntity extends Model { void createAndActiveCard(WalletManageEntity dto); + /** + * 为尚未绑定饭卡的钱包分配空闲卡并建立开卡关联的短事务实现。 + *

饭卡所属商户包含 Feign 远程调用,必须由调用方在事务开启前解析并传入, + * 事务内只保留空闲卡余量检查、必要的独立事务补卡和抢卡绑定,避免拉长抢卡持锁时间。

+ * + * @param wallet 待绑定的饭卡钱包;为空或已有卡号时不执行任何操作 + * @param mch 事务外按钱包部门解析的饭卡所属商户;为空时不自动绑卡 + */ + void createAndActiveCard(WalletManageEntity wallet, MchManageVo mch); + /** * 取消绑定饭卡 */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java index 5e6f0de42..42430b34a 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java @@ -3,13 +3,16 @@ package com.bsd.pjcloud.wallet.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.IService; import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum; import com.bsd.pjcloud.wallet.api.dto.WalletManageBalanceDto; import com.bsd.pjcloud.wallet.api.dto.WalletManageDto; import com.bsd.pjcloud.wallet.api.dto.WalletTopupActionDto; import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; +import com.bsd.pjcloud.wallet.api.entity.WalletTypeEntity; import com.bsd.pjcloud.wallet.api.entity.mch.MchTakeOutSettingEntity; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import com.bsd.pjcloud.wallet.api.vo.WalletManageVo; import com.bsd.pjcloud.wallet.api.vo.WalletQbTotalVo; import jakarta.validation.constraints.NotNull; @@ -35,6 +38,18 @@ public interface WalletManageService extends IService { */ void createWallet(@NotNull Long userId); + /** + * 创建钱包的短事务实现,仅由 {@link #createWallet(Long, Boolean)} 在准备动作完成后经代理调用。 + *

事务内只保留钱包占位查询、钱包插入和饭卡自动绑定,不包含任何远程调用; + * 所需的用户部门、钱包类型全集和饭卡所属商户均由调用方在事务开启前解析传入。

+ * + * @param userId 用户 ID + * @param deptId 用户部门 ID + * @param walletTypes 事务外已查询的钱包类型全集;为空时不创建任何钱包 + * @param eatMch 事务外按用户部门解析的饭卡所属商户;为空时饭卡钱包不自动绑卡 + */ + void createWalletInTx(Long userId, Long deptId, List walletTypes, MchManageVo eatMch); + /** * 获取钱包没有就创建用户钱包(没有缓存) */ @@ -148,6 +163,16 @@ public interface WalletManageService extends IService { */ void userChange(Long userId, Boolean isDel); + /** + * 用户变更同步的短事务实现,仅由 {@link #userChange(Long, Boolean)} 在商户解析完成后经代理调用。 + *

事务内只保留钱包部门和已绑饭卡商户的本地更新;用户资料与商户解析的远程调用、 + * 正元第三方同步均由调用方在事务外执行。

+ * + * @param sysUser 已查询的用户资料,取部门和用户 ID + * @param mch 按用户部门解析的食堂商户;为空时不调整已绑饭卡的商户归属 + */ + void userChangeInTx(SysUser sysUser, MchManageVo mch); + /** * 修改部门 */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java index 843efb0dd..ff6ec9604 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java @@ -40,6 +40,7 @@ import com.bsd.pjcloud.wallet.service.WalletTypeService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; @@ -68,7 +69,9 @@ public class WalletActiveServiceImpl extends ServiceImpl学生学校可能挂在“其他食堂”商户下,因此自动绑卡同时匹配其他食堂和自营食堂; - * 钱包已有卡号时视为已绑定并保持原行为,不重复分配。

+ * 钱包已有卡号时视为已绑定并保持原行为,不重复分配。商户解析包含 Feign 远程调用, + * 在事务外完成后委托短事务重载执行,避免远程调用拉长抢卡事务持锁时间。

* * @param wallet 待绑定的饭卡钱包;为空或已有卡号时不执行任何操作 */ @Override - @Transactional(rollbackFor = Exception.class) public void createAndActiveCard(WalletManageEntity wallet) { if (ObjectUtil.isEmpty(wallet) || ObjectUtil.isNotEmpty(wallet.getCardNumber())) return; // 学校部门可能归属其他食堂,必须与用户部门商户关系同步时采用相同的食堂类型范围。 MchManageVo mch = mchManageService.getFirstMchByDeptId(wallet.getDeptId(), List.of(MerchantTypeEnum.OTHER_CANTEEN, MerchantTypeEnum.CANTEEN)); if (ObjectUtil.isEmpty(mch)) return; + getSelf().createAndActiveCard(wallet, mch); + } + + /** + * 为尚未绑定饭卡的钱包分配空闲卡并建立开卡关联的短事务实现,商户由调用方在事务外解析。 + *

事务内仅包含空闲卡余量 COUNT、必要时的独立事务补卡和抢卡绑定: + * 抢卡 SELECT ... FOR UPDATE 依赖 idx_wallet_active_merchant_user_sort 复合索引将锁定范围 + * 收敛到队首一张卡;卡池余量不足时的批量补卡走独立事务(见 {@link #fastCreateCard}), + * 避免插入意向锁与抢卡锁互相等待造成死锁。

+ * + * @param wallet 待绑定的饭卡钱包;为空或已有卡号时不执行任何操作 + * @param mch 事务外按钱包部门解析的饭卡所属商户;为空时不自动绑卡 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void createAndActiveCard(WalletManageEntity wallet, MchManageVo mch) { + if (ObjectUtil.isEmpty(wallet) || ObjectUtil.isNotEmpty(wallet.getCardNumber()) || ObjectUtil.isEmpty(mch)) return; LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .isNull(WalletActiveEntity::getUserId) .isNull(WalletActiveEntity::getWalletId) @@ -206,6 +226,7 @@ public class WalletActiveServiceImpl extends ServiceImpl用户资料、权限参数、钱包类型全集和饭卡所属商户(均含远程调用)全部在事务外解析, + * 随后经代理调用短事务 {@link #createWalletInTx} 完成写库;抢卡锁冲突(死锁/锁等待超时) + * 时 MySQL 已回滚整笔短事务,直接重试即可,用尽次数后原样抛出。 + * 钱包已存在的用户保持原有行为跳过,不重复自动绑卡。

+ * + * @param userId 用户 ID + * @param checkPermission 是否按 WALLET_AUTO_CREATE 角色参数控制自动开户 + */ @Override - @Transactional(rollbackFor = Exception.class) public void createWallet(Long userId, Boolean checkPermission) { UserInfo user = null; if (checkPermission) { @@ -119,22 +135,22 @@ public class WalletManageServiceImpl extends ServiceImpl list = walletTypeMapper.selectList(new LambdaQueryWrapper<>()); if (ObjectUtil.isEmpty(list)) return; if (ObjectUtil.isEmpty(user)) user = userService.getInnerByUserId(userId).getData(); - for (WalletTypeEntity v : list) { - WalletManageEntity wallet = baseMapper.selectOne(new LambdaQueryWrapper() - .eq(WalletManageEntity::getUserId, userId) - .eq(WalletManageEntity::getWalletTypeId, v.getId()) - ); - // 创建钱包 - WalletManageEntity insertObj = new WalletManageEntity(); - if (ObjectUtil.isEmpty(wallet)) { - insertObj.setUserId(userId); - insertObj.setWalletTypeId(v.getId()); - insertObj.setDeptId(user.getSysUser().getDeptId()); - baseMapper.insert(insertObj); - } - // 默认创建饭卡并绑定饭卡钱包 - if (ObjectUtil.equals(v.getUniqueCode(), WalletTypeEnum.EAT.getCode())) { - walletActiveService.createAndActiveCard(insertObj); + Long deptId = user.getSysUser().getDeptId(); + // 饭卡所属商户解析包含 Feign 远程调用,必须在事务开启前完成,避免拉长抢卡事务持锁时间。 + MchManageVo eatMch = null; + if (list.stream().anyMatch(v -> ObjectUtil.equals(v.getUniqueCode(), WalletTypeEnum.EAT.getCode()))) { + eatMch = mchManageService.getFirstMchByDeptId(deptId, + List.of(MerchantTypeEnum.OTHER_CANTEEN, MerchantTypeEnum.CANTEEN)); + } + for (int attempt = 1; ; attempt++) { + try { + getSelf().createWalletInTx(userId, deptId, list, eatMch); + return; + } catch (PessimisticLockingFailureException ex) { + // 死锁/锁等待超时发生时短事务已整体回滚,未产生部分创建的钱包,可直接重试 + if (attempt >= CREATE_WALLET_MAX_ATTEMPTS) throw ex; + log.warn("|{}|创建钱包抢卡锁冲突,第{}次重试,userId:{}", TAG, attempt, userId, ex); + sleepBeforeWalletRetry(attempt, ex); } } } @@ -144,6 +160,54 @@ public class WalletManageServiceImpl extends ServiceImpl仅经代理由 {@link #createWallet(Long, Boolean)} 调用;事务内不发起任何远程调用, + * 避免网络往返期间持有抢卡行锁。饭卡类型钱包仅在本次新建成功后才自动绑卡, + * 已有钱包(含并发请求刚创建的)直接跳过,与原行为保持一致。

+ * + * @param userId 用户 ID + * @param deptId 事务外解析的用户部门 ID + * @param walletTypes 事务外查询的钱包类型全集 + * @param eatMch 事务外解析的饭卡所属商户;为空时饭卡钱包不自动绑卡 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void createWalletInTx(Long userId, Long deptId, List walletTypes, MchManageVo eatMch) { + for (WalletTypeEntity v : walletTypes) { + WalletManageEntity wallet = baseMapper.selectOne(new LambdaQueryWrapper() + .eq(WalletManageEntity::getUserId, userId) + .eq(WalletManageEntity::getWalletTypeId, v.getId()) + ); + if (ObjectUtil.isNotEmpty(wallet)) continue; + // 创建钱包 + WalletManageEntity insertObj = new WalletManageEntity(); + insertObj.setUserId(userId); + insertObj.setWalletTypeId(v.getId()); + insertObj.setDeptId(deptId); + baseMapper.insert(insertObj); + // 默认创建饭卡并绑定饭卡钱包 + if (ObjectUtil.equals(v.getUniqueCode(), WalletTypeEnum.EAT.getCode())) { + walletActiveService.createAndActiveCard(insertObj, eatMch); + } + } + } + + /** + * 抢卡锁冲突重试前的递增退避;线程被中断时恢复中断标记并终止重试。 + * + * @param attempt 当前已失败的尝试序号(从 1 开始) + * @param cause 触发重试的锁冲突异常,中断时原样抛出 + */ + private void sleepBeforeWalletRetry(int attempt, PessimisticLockingFailureException cause) { + try { + Thread.sleep(CREATE_WALLET_RETRY_BACKOFF_MILLIS * attempt); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw cause; + } + } + @Override public WalletManageEntity getOrCreateWallet(Long userId, WalletTypeEnum walletTypeCode) { WalletTypeEntity walletType = walletTypeService.getByUniqueCode(walletTypeCode); @@ -534,8 +598,16 @@ public class WalletManageServiceImpl extends ServiceImpl用户资料查询与食堂商户解析(均含远程调用)在事务外完成,本地更新经短事务 + * {@link #userChangeInTx} 提交;正元第三方同步放在事务提交后执行, + * 避免远程调用和第三方逻辑拉长事务持锁时间,同步异常也不再回滚本地部门变更。

+ * + * @param userId 变更用户 ID + * @param isDel 是否删除事件;仅处理非删除事件 + */ @Override - @Transactional(rollbackFor = Exception.class) public void userChange(Long userId, Boolean isDel) { if (ObjectUtil.isEmpty(userId)) return; if (ObjectUtil.isEmpty(isDel)) return; @@ -543,27 +615,39 @@ public class WalletManageServiceImpl extends ServiceImpl() - .set(WalletManageEntity::getDeptId, sysUser.getDeptId()) - .ne(WalletManageEntity::getDeptId, sysUser.getDeptId()) - .eq(WalletManageEntity::getUserId, sysUser.getUserId()) - ); - // 修改钱包冻结开卡商户 - // 查询用户所属于的食堂商户 + // 查询用户所属于的食堂商户,解析含远程调用,先于事务执行 MchManageVo mch = mchManageService.getFirstMchByDeptId(sysUser.getDeptId(), List.of(MerchantTypeEnum.CANTEEN)); - if (ObjectUtil.isNotEmpty(mch)) { - walletActiveService.update(new LambdaUpdateWrapper() - .set(WalletActiveEntity::getMerchantId, mch.getId()) - .ne(WalletActiveEntity::getMerchantId, mch.getId()) - .eq(WalletActiveEntity::getUserId, sysUser.getUserId()) - ); - } - // 同步到正元 + getSelf().userChangeInTx(sysUser, mch); + // 同步到正元:本地更新提交后执行,失败不影响钱包部门变更 walletThirdZyService.syncAllUser(userId); } } + /** + * 用户变更同步的短事务实现:钱包部门与已绑饭卡商户的本地更新。 + * + * @param sysUser 已查询的用户资料,取部门和用户 ID + * @param mch 按用户部门解析的食堂商户;为空时不调整已绑饭卡的商户归属 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void userChangeInTx(SysUser sysUser, MchManageVo mch) { + // 修改钱包部门 + baseMapper.update(new LambdaUpdateWrapper() + .set(WalletManageEntity::getDeptId, sysUser.getDeptId()) + .ne(WalletManageEntity::getDeptId, sysUser.getDeptId()) + .eq(WalletManageEntity::getUserId, sysUser.getUserId()) + ); + // 修改钱包冻结开卡商户 + if (ObjectUtil.isNotEmpty(mch)) { + walletActiveService.update(new LambdaUpdateWrapper() + .set(WalletActiveEntity::getMerchantId, mch.getId()) + .ne(WalletActiveEntity::getMerchantId, mch.getId()) + .eq(WalletActiveEntity::getUserId, sysUser.getUserId()) + ); + } + } + @Override public void deptChange(Long deptId, Boolean isDel) { if (ObjectUtil.isEmpty(deptId)) return; 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 5572585b7..860dffe5d 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 @@ -167,6 +167,8 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply .userDeptId(row.getUserDeptId()) .invoiceId(invoice.getId()) .tags(row.getTags()) + // Excel"备注"列随明细落库,审批通过后写入钱包流水 remark + .remark(row.getRemark()) .build()) .toList(); walletRechargeImportService.saveBatch(details); @@ -197,6 +199,8 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply .userId(row.getUserId()) .userDeptId(row.getUserDeptId()) .tags(row.getTags()) + // Excel"备注"列随明细落库,审批通过后写入钱包流水 remark + .remark(row.getRemark()) .build()) .toList(); walletRechargeImportService.saveBatch(details); @@ -227,6 +231,8 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply .userId(row.getUserId()) .userDeptId(row.getUserDeptId()) .tags(row.getTags()) + // Excel"备注"列随明细落库,审批通过后写入钱包流水 remark + .remark(row.getRemark()) .build()) .toList(); walletRechargeImportService.saveBatch(details); diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java index 92986ffe4..3829354b5 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java @@ -369,7 +369,8 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl userIds = importList.stream().map(WalletRechargeImportEntity::getUserId).distinct().toList();