diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java index d1e462e3..0ba972bd 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java @@ -24,6 +24,26 @@ public class HardCarVo extends HardCarEntity { /** 商品购买次数汇总值,仅统计查询赋值。 */ private Long payCount; + /** + * 原订单购买份数,只读展示字段;普通订单商品列表中与持久化 {@link #getProductCount()} 一致。 + */ + private Integer originalProductCount; + + /** + * 已完成退款份数,只统计售后状态为完成的商品明细;空响应按零处理。 + */ + private Integer refundedProductCount; + + /** + * 申请中退款份数,只统计仍占用退款额度的待处理售后;不从当前可履约数量中提前扣除。 + */ + private Integer applyingProductCount; + + /** + * 当前剩余可履约份数,计算规则为原购份数减已完成退款份数,最小为零。 + */ + private Integer effectiveProductCount; + /** 统计结果所属商户 ID,不作为购物车持久化字段。 */ private Long merchantId; diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java index 02d14e94..d5f55295 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java @@ -11,10 +11,12 @@ import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum; import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; import com.bsd.pjcloud.hard.api.dto.async.SyncOfflineReqVo; import com.bsd.pjcloud.hard.api.dto.hard.*; import com.bsd.pjcloud.hard.api.dto.order.HardChartLatitudeVo; import com.bsd.pjcloud.hard.api.dto.order.HardCreateCarDto; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import com.bsd.pjcloud.hard.api.vo.HardInvoiceVo; import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo; import com.bsd.pjcloud.hard.api.vo.android.HardAdUserVo; @@ -37,6 +39,7 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.List; @RestController @@ -96,9 +99,63 @@ public class HardApiController { } @GetMapping("/get-by-order-id") - @Operation(summary = "根据订单id查询购物车详情") + @Operation(summary = "根据订单id查询终端待履约购物车详情") public R> getByOrderId(@RequestParam Long orderId) { - return R.ok(hardCarService.getByOrderId(orderId)); + List carList = hardCarService.getByOrderId(orderId); + HardOrderEntity order = hardwareOrderService.getById(orderId); + return R.ok(projectFulfillableReservationCars(order, carList)); + } + + /** + * 食堂预约核销只向消费机暴露退款成功后的剩余商品;其他订单继续保持原接口数量和金额语义。 + * + *

App 订单详情使用独立接口,仍可同时读取原购、已退和剩余数量。这里覆盖旧版食堂、超市消费机 + * 共用的二维码核销接口,避免终端继续按原购物车数量出餐。

+ * + * @param order 订单快照,用于限定食堂预约场景 + * @param carList 带退款数量统计的购物车展示对象 + * @return 终端可继续履约的商品;非食堂预约订单原样返回 + */ + private List projectFulfillableReservationCars(HardOrderEntity order, List carList) { + if (ObjectUtil.isEmpty(carList) || order == null + || !ObjectUtil.equals(order.getSysType(), HardTypeEnum.CANTEEN.getType()) + || !ObjectUtil.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())) { + return carList; + } + return carList.stream().filter(ObjectUtil::isNotEmpty).filter(car -> resolveEffectiveCount(car) > 0).peek(car -> { + int originalCount = resolveOriginalCount(car); + int effectiveCount = resolveEffectiveCount(car); + car.setProductCount(effectiveCount); + if (car.getTotalPrice() != null && originalCount > 0 && effectiveCount < originalCount) { + long effectiveTotalPrice = BigDecimal.valueOf(car.getTotalPrice()) + .multiply(BigDecimal.valueOf(effectiveCount)) + .divide(BigDecimal.valueOf(originalCount), 0, RoundingMode.HALF_UP) + .longValue(); + car.setTotalPrice(effectiveTotalPrice); + } + }).toList(); + } + + /** + * 解析购物车原购份数,旧响应缺少显式快照字段时回退持久化数量。 + * + * @param car 购物车展示对象 + * @return 非负原购份数 + */ + private int resolveOriginalCount(HardCarVo car) { + Integer count = car.getOriginalProductCount() == null ? car.getProductCount() : car.getOriginalProductCount(); + return count == null ? 0 : Math.max(0, count); + } + + /** + * 解析退款成功后的剩余可履约份数,旧数据缺少统计字段时回退原购份数。 + * + * @param car 购物车展示对象 + * @return 非负剩余份数 + */ + private int resolveEffectiveCount(HardCarVo car) { + Integer count = car.getEffectiveProductCount() == null ? resolveOriginalCount(car) : car.getEffectiveProductCount(); + return count == null ? 0 : Math.max(0, count); } @GetMapping("/get-order") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAfterSaleCarMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAfterSaleCarMapper.java index 5a374397..b9312e94 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAfterSaleCarMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAfterSaleCarMapper.java @@ -7,6 +7,9 @@ import org.apache.ibatis.annotations.Param; import java.util.List; +/** + * 售后商品快照 Mapper,负责按订单父购物车和售后状态聚合退款占用量。 + */ @Mapper public interface HardAfterSaleCarMapper extends PjcloudBaseMapper { @@ -19,6 +22,16 @@ public interface HardAfterSaleCarMapper extends PjcloudBaseMapper getOccupiedByCarParentId(@Param("carParentId") Long carParentId, @Param("statuses") List statuses); + /** + * 批量查询父购物车在指定售后状态下的商品占用汇总,结果按父购物车和商品 ID 分组。 + * + * @param carParentIds 父购物车 ID 集合 + * @param statuses 计入占用的售后状态编码集合 + * @return 每个父购物车下按商品 ID 聚合的占用明细,无匹配数据时返回空列表 + */ + List getOccupiedByCarParentIds(@Param("carParentIds") List carParentIds, + @Param("statuses") List statuses); + /** * 查询指定售后单保存的商品快照,审核同意时据此退库存。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java index d88ad49b..bcf822aa 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java @@ -27,7 +27,7 @@ import java.util.List; public interface HardOrderMapper extends PjcloudBaseMapper { /** - * 按主键查询并锁定订单行,供退款创建、审核、取消和后台直退串行化同一订单。 + * 按主键查询并锁定订单行,供退款创建、审核、取消、后台直退和预约核销串行化同一订单。 * * @param id 订单主键 * @return 锁定后的订单;不存在时返回 {@code null} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java index 2151d427..77f024c4 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java @@ -4,9 +4,14 @@ import com.baomidou.mybatisplus.extension.service.IService; import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleCarEntity; import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; +import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import java.util.List; +import java.util.Map; +/** + * 售后商品快照与订单商品退款数量聚合服务。 + */ public interface HardAfterSaleCarService extends IService { /** @@ -42,6 +47,21 @@ public interface HardAfterSaleCarService extends IService getOccupiedByCarParentId(Long carParentId, List statuses); + /** + * 按售后状态为订单购物车展示对象补充原购、已退、申请中和剩余份数。 + * + * @param carParentId 原订单父购物车 ID + * @param carList 原订单购物车展示对象;原始 {@code productCount} 不会被修改 + */ + void fillRefundQuantityStats(Long carParentId, List carList); + + /** + * 批量补充多个订单购物车的退款数量统计,整个列表固定执行两次售后聚合查询。 + * + * @param carListsByParentId 父购物车 ID 到原订单购物车展示对象的映射 + */ + void fillRefundQuantityStats(Map> carListsByParentId); + /** * 查询单个售后申请保存的商品快照。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImpl.java index bb227926..aa980c5f 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImpl.java @@ -27,6 +27,7 @@ import com.bsd.pjcloud.hard.api.vo.android.ReservationMenuQueryVo; import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; import com.bsd.pjcloud.hard.service.HardApiService; +import com.bsd.pjcloud.hard.service.HardCarService; import com.bsd.pjcloud.hard.service.HardManagerService; import com.bsd.pjcloud.hard.service.HardOrderService; import com.bsd.pjcloud.hard.service.ReservationMenuQueryService; @@ -54,6 +55,10 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ private final HardManagerService hardManagerService; private final HardApiService hardApiService; private final HardOrderService hardOrderService; + + /** 订单购物车服务,用于在直接核销接口中再次确认至少存在一份可履约商品。 */ + private final HardCarService hardCarService; + private final RemoteUserService remoteUserService; private final RemoteDeptService remoteDeptService; private final RemoteMchService remoteMchService; @@ -61,6 +66,7 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ /** 食堂菜品远程服务,用于批量补充订单商品类型和套餐组成。 */ private final RemoteCanteenSpuService remoteCanteenSpuService; + /** {@inheritDoc} */ /** {@inheritDoc} */ @Override public ReservationMenuQueryVo query(ReservationMenuQueryDto dto) { @@ -72,7 +78,9 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ query.setMerchantId(terminalMerchant.merchant().getId()); query.setOrderType(HardOrderTypeEnum.RESERVE.getType()); query.setOrderStatusCodes(List.of(HardOrderStatusEnum.PAY.getType())); - query.setRefundStatusCodes(List.of(HardRefundStatusEnum.NONE.getStatus(), HardRefundStatusEnum.REJECT.getStatus())); + // 部分退款成功时订单仍为已支付并可继续核销,只有全额退款会由订单状态过滤掉。 + query.setRefundStatusCodes(List.of(HardRefundStatusEnum.NONE.getStatus(), HardRefundStatusEnum.REJECT.getStatus(), + HardRefundStatusEnum.SUCCESS.getStatus())); query.setApptIdOrApptRuleIdNotNull(true); query.setApptUseDateStart(LocalDate.now().atStartOfDay()); query.setIsFindCarList(true); @@ -84,11 +92,13 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ return aggregate(recognizedUser, terminalMerchant, orders, dto.getMaskName(), productMetadata); } + /** {@inheritDoc} */ @Override public Boolean cancel(ReservationMenuCancelDto dto) { return cancel(dto, null); } + /** {@inheritDoc} */ @Override public Boolean cancel(ReservationMenuCancelDto dto, Long hardId) { HardOrderEntity order = hardOrderService.getById(dto.getOrderId()); @@ -98,11 +108,14 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ throw new RuntimeException("该预约订单当前不可核销"); } if (!isCancelableRefundStatus(order.getRefundStatus())) { - throw new RuntimeException("该预约订单正在退款或已退款,不能核销"); + throw new RuntimeException("该预约订单正在退款,不能核销"); } if (order.getApptUseDate() == null || order.getApptUseDate().isBefore(LocalDate.now())) { throw new RuntimeException("该预约订单已失效"); } + if (!hasFulfillableProduct(order.getId())) { + throw new RuntimeException("该预约订单商品已全部退款,不能核销"); + } HandCancelCodeDto cancelDto = new HandCancelCodeDto(); cancelDto.setOrderId(order.getId()); @@ -111,6 +124,17 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ return hardOrderService.handCancelCode(cancelDto); } + /** + * 校验订单至少还有一份退款成功后可履约的商品,防止旧终端绕过查询结果直接核销空订单。 + * + * @param orderId 待核销预约订单 ID + * @return 存在剩余商品时返回 {@code true} + */ + private boolean hasFulfillableProduct(Long orderId) { + List cars = hardCarService.getByOrderId(orderId); + return cars != null && cars.stream().anyMatch(car -> resolveEffectiveProductCount(car) > 0); + } + private Long resolveVerificationHardId(HardOrderEntity order, Long hardId) { if (hardId == null) return -1L; HardManagerEntity hard = hardManagerService.getById(hardId); @@ -120,10 +144,17 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ return hard.getId(); } + /** + * 判断预约订单退款聚合状态是否允许继续履约,历史空值按未售后兼容,未知状态按不可核销处理。 + * + * @param refundStatus 订单退款聚合状态编码 + * @return 未售后、退款拒绝或部分退款成功时返回 {@code true} + */ private static boolean isCancelableRefundStatus(String refundStatus) { return StrUtil.isBlank(refundStatus) || Objects.equals(refundStatus, HardRefundStatusEnum.NONE.getStatus()) - || Objects.equals(refundStatus, HardRefundStatusEnum.REJECT.getStatus()); + || Objects.equals(refundStatus, HardRefundStatusEnum.REJECT.getStatus()) + || Objects.equals(refundStatus, HardRefundStatusEnum.SUCCESS.getStatus()); } private TerminalMerchant resolveTerminalMerchant(Long hardId) { @@ -263,27 +294,28 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ Map> grouped = new TreeMap<>(); for (HardOrderVo order : orders) { + List fulfillableItems = new ArrayList<>(); + if (order.getCarList() != null) { + for (HardCarVo car : order.getCarList()) { + int effectiveCount = resolveEffectiveProductCount(car); + if (effectiveCount <= 0 || StrUtil.isBlank(car.getProductName())) continue; + // 历史购物车允许商品 ID 为空,不应拿空键访问 Map.of() 这类不可变映射。 + ProductMetadata metadata = car.getProductId() == null ? null : productMetadata.get(car.getProductId()); + fulfillableItems.add(new OrderItemAccumulator( + car.getProductId(), car.getProductName().trim(), car.getProductPic(), effectiveCount, + metadata == null ? 1 : metadata.spuType(), + metadata == null ? List.of() : metadata.comboItems())); + } + } + // 全部商品退款后不能只留下一个空订单核销入口,订单和餐段都应从结果中消失。 + if (fulfillableItems.isEmpty()) continue; String hourType = resolveHourType(order); LinkedHashMap meals = grouped.computeIfAbsent(order.getApptUseDate(), ignored -> new LinkedHashMap<>()); MealAccumulator meal = meals.computeIfAbsent(hourType, ignored -> new MealAccumulator(hourType, resolveMealTitle(order, hourType))); - List orderItems = null; + fulfillableItems.forEach(item -> meal.items().add(item.productName())); if (order.getId() != null) { meal.orderIds().add(order.getId()); - orderItems = meal.orderItems().computeIfAbsent(order.getId(), ignored -> new ArrayList<>()); - } - if (order.getCarList() == null) continue; - for (HardCarVo car : order.getCarList()) { - if (car != null && StrUtil.isNotBlank(car.getProductName())) { - String productName = car.getProductName().trim(); - meal.items().add(productName); - if (orderItems != null) { - ProductMetadata metadata = productMetadata.get(car.getProductId()); - orderItems.add(new OrderItemAccumulator( - car.getProductId(), productName, car.getProductPic(), car.getProductCount(), - metadata == null ? 1 : metadata.spuType(), - metadata == null ? List.of() : metadata.comboItems())); - } - } + meal.orderItems().put(order.getId(), fulfillableItems); } } @@ -309,6 +341,18 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ return result; } + /** + * 解析退款成功后的剩余可履约份数;旧服务数据缺少统计字段时回退原购买数量。 + * + * @param car 订单购物车展示对象 + * @return 可出现在终端待核销菜单中的非负份数 + */ + private static int resolveEffectiveProductCount(HardCarVo car) { + if (car == null) return 0; + Integer count = car.getEffectiveProductCount() == null ? car.getProductCount() : car.getEffectiveProductCount(); + return count == null ? 0 : Math.max(0, count); + } + /** * 将单个餐段聚合结果转换为终端响应,并保持订单及商品的稳定顺序。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java index 11ecfed6..539dc118 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java @@ -6,6 +6,7 @@ import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleCarEntity; import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import com.bsd.pjcloud.hard.api.constant.refund.HardSaleStatusEnum; +import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleCarMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.HardAfterSaleCarService; @@ -16,10 +17,12 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** - * 购物车退款表 + * 售后商品快照服务实现,保留原购物车数量并为各消费端补充退款后的只读数量口径。 */ @Validated @Slf4j @@ -27,6 +30,7 @@ import java.util.List; @Service public class HardAfterSaleCarServiceImpl extends ServiceImpl implements HardAfterSaleCarService { + /** 订单 Mapper,用于从订单 ID 解析其父购物车 ID。 */ private final HardOrderMapper hardOrderMapper; @Override @@ -68,6 +72,101 @@ public class HardAfterSaleCarServiceImpl extends ServiceImpl同一商品存在多行历史快照时按购物车顺序分配累计退款份数,避免把商品级汇总重复写入每一行。

+ * + * @param carParentId 原订单父购物车 ID + * @param carList 原订单购物车展示对象;方法只写入非持久化统计字段 + */ + @Override + public void fillRefundQuantityStats(Long carParentId, List carList) { + if (carParentId == null || ObjectUtil.isEmpty(carList)) return; + fillRefundQuantityStats(Map.of(carParentId, carList)); + } + + /** + * 批量统计避免订单列表按每笔订单额外执行两次售后查询;每个购物车仍独立分配商品级累计份数。 + * + * @param carListsByParentId 父购物车 ID 到原订单购物车展示对象的映射 + */ + @Override + public void fillRefundQuantityStats(Map> carListsByParentId) { + if (ObjectUtil.isEmpty(carListsByParentId)) return; + List parentIds = carListsByParentId.keySet().stream().filter(java.util.Objects::nonNull).distinct().toList(); + if (parentIds.isEmpty()) return; + Map> refundedByParent = quantityMap(parentIds, HardSaleStatusEnum.COMPLETE.getStatus()); + Map> applyingByParent = quantityMap(parentIds, HardSaleStatusEnum.APPLY.getStatus()); + carListsByParentId.forEach((parentId, carList) -> fillCarQuantityStats(carList, + new LinkedHashMap<>(refundedByParent.getOrDefault(parentId, Map.of())), + new LinkedHashMap<>(applyingByParent.getOrDefault(parentId, Map.of())))); + } + + /** + * 为同一父购物车的每行商品分配累计售后份数,重复商品行不会重复消费同一份退款。 + * + * @param carList 原订单购物车展示对象 + * @param refundedQuantities 尚未分配的已退款商品份数 + * @param applyingQuantities 尚未分配的申请中商品份数 + */ + private void fillCarQuantityStats(List carList, Map refundedQuantities, + Map applyingQuantities) { + if (ObjectUtil.isEmpty(carList)) return; + for (HardCarVo car : carList) { + int originalCount = safeCount(car.getProductCount()); + int refundedCount = takeQuantity(refundedQuantities, car.getProductId(), originalCount); + int applyingCount = takeQuantity(applyingQuantities, car.getProductId(), originalCount - refundedCount); + car.setOriginalProductCount(originalCount); + car.setRefundedProductCount(refundedCount); + car.setApplyingProductCount(applyingCount); + car.setEffectiveProductCount(Math.max(0, originalCount - refundedCount)); + } + } + + /** + * 查询单一售后状态下各商品累计份数,并转换为后续可扣减的可变映射。 + * + * @param carParentIds 原订单父购物车 ID 集合 + * @param status 售后状态协议编码 + * @return 父购物车 ID 到商品累计份数映射,异常负数按零处理 + */ + private Map> quantityMap(List carParentIds, String status) { + Map> result = new LinkedHashMap<>(); + for (HardAfterSaleCarEntity item : baseMapper.getOccupiedByCarParentIds(carParentIds, List.of(status))) { + if (item.getParentCarId() == null || item.getProductId() == null) continue; + result.computeIfAbsent(item.getParentCarId(), ignored -> new LinkedHashMap<>()) + .merge(item.getProductId(), safeCount(item.getProductCount()), Integer::sum); + } + return result; + } + + /** + * 从商品级累计份数中为当前购物车行领取不超过本行上限的数量,并保存剩余值供重复商品行继续领取。 + * + * @param quantities 商品 ID 到尚未分配份数的映射 + * @param productId 当前购物车商品 ID + * @param limit 当前行最多允许分配的份数 + * @return 分配给当前购物车行的非负份数 + */ + private int takeQuantity(Map quantities, Long productId, int limit) { + if (productId == null || limit <= 0) return 0; + int remaining = Math.max(0, quantities.getOrDefault(productId, 0)); + int taken = Math.min(limit, remaining); + quantities.put(productId, remaining - taken); + return taken; + } + + /** + * 将数据库可空商品份数转换为非负整数,防止历史异常值扩大剩余数量。 + * + * @param count 数据库商品份数 + * @return 可参与退款统计的非负份数 + */ + private int safeCount(Integer count) { + return count == null ? 0 : Math.max(0, count); + } + /** * 审核与详情展示只读取申请时保存的商品快照,避免依赖已变化的商品主数据。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java index 4e2aee2b..0c37f307 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java @@ -29,6 +29,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.mapper.order.HardCarMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.mapper.order.HardParentCarMapper; +import com.bsd.pjcloud.hard.service.HardAfterSaleCarService; import com.bsd.pjcloud.hard.service.HardCarService; import com.bsd.pjcloud.hard.service.HardSpuService; import com.bsd.pjcloud.supermarket.api.feign.RemoteMallProductService; @@ -75,6 +76,9 @@ public class HardCarServiceImpl extends ServiceImpl list = getByParentCarId(order.getParentCarId()); List result = BeanUtils.toBean(list, HardCarVo.class); fillAppProductDetailMetadata(order.getParentCarId(), result); + hardAfterSaleCarService.fillRefundQuantityStats(order.getParentCarId(), result); return result; } 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 994f6550..e8b94040 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 @@ -48,6 +48,7 @@ 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.HardPayTypeEnum; +import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum; import com.bsd.pjcloud.hard.api.constant.xfj.HardXfjTypeEnum; import com.bsd.pjcloud.hard.api.dto.hard.HandCancelActCodeDto; import com.bsd.pjcloud.hard.api.dto.hard.HardDoneHardQrCodeScanOrderDto; @@ -182,6 +183,8 @@ public class HardOrderServiceImpl extends ServiceImpl carList = hardCarService.getByParentCarId(detail.getParentCarId()); List carVoList = BeanUtils.toBean(carList, HardCarVo.class); + hardAfterSaleCarService.fillRefundQuantityStats(detail.getParentCarId(), carVoList); detail.setCarList(carVoList); }); // 预约订单 @@ -3415,12 +3419,19 @@ public class HardOrderServiceImpl extends ServiceImpl申请中的商品仍属于原订单履约资源,但为避免退款与出餐同时成功,整笔订单在申请处理期间暂停核销; + * 部分退款完成后只要仍有剩余商品即可继续核销。其他订单类型保持通用核销接口的历史行为。

+ * + * @param order 已持有数据库行锁的订单快照 + */ + private void validateCanteenReservationBeforeCancel(HardOrderEntity order) { + boolean isCanteenReservation = ObjectUtil.equals(order.getSysType(), HardTypeEnum.CANTEEN.getType()) + && ObjectUtil.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType()); + if (!isCanteenReservation) return; + if (!ObjectUtil.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType())) { + throw new RuntimeException("该预约订单当前不可核销"); + } + if (!isReservationRefundStatusFulfillable(order.getRefundStatus())) { + throw new RuntimeException("该预约订单正在退款,不能核销"); + } + List cars = hardCarService.getByOrderId(order.getId()); + boolean hasFulfillableProduct = ObjectUtil.isNotEmpty(cars) + && cars.stream().anyMatch(car -> resolveEffectiveProductCount(car) > 0); + if (!hasFulfillableProduct) { + throw new RuntimeException("该预约订单商品已全部退款,不能核销"); + } + } + + /** + * 判断订单聚合退款状态是否允许预约履约;未知状态按不可核销处理,避免未来状态被旧终端误放行。 + * + * @param refundStatus 订单退款聚合状态,历史空值等同未售后 + * @return 未售后、退款拒绝或部分退款成功时返回 {@code true} + */ + private boolean isReservationRefundStatusFulfillable(String refundStatus) { + return StrUtil.isBlank(refundStatus) + || ObjectUtil.equals(refundStatus, HardRefundStatusEnum.NONE.getStatus()) + || ObjectUtil.equals(refundStatus, HardRefundStatusEnum.REJECT.getStatus()) + || ObjectUtil.equals(refundStatus, HardRefundStatusEnum.SUCCESS.getStatus()); + } + + /** + * 解析购物车退款成功后的剩余可履约份数,兼容历史响应只包含原购买数量的情况。 + * + * @param car 带售后统计的订单商品快照 + * @return 非负剩余份数 + */ + private int resolveEffectiveProductCount(HardCarVo car) { + if (car == null) return 0; + Integer count = car.getEffectiveProductCount() == null ? car.getProductCount() : car.getEffectiveProductCount(); + return count == null ? 0 : Math.max(0, count); + } + /** * 在预约订单PAY到CANCEL状态转换的同一本地事务内保存用餐核销消息快照。 * @@ -4249,11 +4311,14 @@ public class HardOrderServiceImpl extends ServiceImpl findCarListFuture = CompletableFuture.runAsync(() -> { if (dto.getIsFindCarList()) { + Map> carListsByParentId = new LinkedHashMap<>(); for (HardOrderVo v : list) { List carList = hardCarService.getByParentCarId(v.getParentCarId()); List carVoList = BeanUtils.toBean(carList, HardCarVo.class); + if (v.getParentCarId() != null) carListsByParentId.put(v.getParentCarId(), carVoList); v.setCarList(carVoList); } + hardAfterSaleCarService.fillRefundQuantityStats(carListsByParentId); } }); // 预约订单 diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleCarMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleCarMapper.xml index 33ffb43b..d4bb1874 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleCarMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleCarMapper.xml @@ -84,6 +84,32 @@ group by a.parent_car_id, a.product_id + + select * from hard_order diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/controller/hard/HardApiControllerTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/controller/hard/HardApiControllerTest.java new file mode 100644 index 00000000..ef14745f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/controller/hard/HardApiControllerTest.java @@ -0,0 +1,94 @@ +package com.bsd.pjcloud.hard.controller.hard; + +import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; +import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; +import com.bsd.pjcloud.hard.service.HardApiService; +import com.bsd.pjcloud.hard.service.HardCarService; +import com.bsd.pjcloud.hard.service.HardInvoiceService; +import com.bsd.pjcloud.hard.service.HardManagerService; +import com.bsd.pjcloud.hard.service.HardOrderService; +import com.bsd.pjcloud.hard.service.HardOrderSyncService; +import com.bsd.pjcloud.hard.service.HardOrderTotalService; +import com.bsd.pjcloud.hard.service.HardTerminalStatusService; +import com.bsd.pjcloud.hard.service.ReservationMenuQueryService; +import com.bsd.pjcloud.hard.service.VerificationRecordService; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Android 终端购物车接口测试,验证食堂预约核销只返回退款后的剩余商品。 + */ +class HardApiControllerTest { + + /** 硬件配置服务,本组测试不调用。 */ + private final HardManagerService hardManagerService = mock(HardManagerService.class); + + /** 发票服务,本组测试不调用。 */ + private final HardInvoiceService hardInvoiceService = mock(HardInvoiceService.class); + + /** 购物车服务,提供带退款统计的订单商品。 */ + private final HardCarService hardCarService = mock(HardCarService.class); + + /** 订单服务,提供用于限定食堂预约场景的订单快照。 */ + private final HardOrderService hardOrderService = mock(HardOrderService.class); + + /** 离线订单同步服务,本组测试不调用。 */ + private final HardOrderSyncService hardOrderSyncService = mock(HardOrderSyncService.class); + + /** 硬件通用服务,本组测试不调用。 */ + private final HardApiService hardApiService = mock(HardApiService.class); + + /** 订单统计服务,本组测试不调用。 */ + private final HardOrderTotalService hardOrderTotalService = mock(HardOrderTotalService.class); + + /** 预约菜单查询服务,本组测试不调用。 */ + private final ReservationMenuQueryService reservationMenuQueryService = mock(ReservationMenuQueryService.class); + + /** 核销记录服务,本组测试不调用。 */ + private final VerificationRecordService verificationRecordService = mock(VerificationRecordService.class); + + /** 终端心跳服务,本组测试不调用。 */ + private final HardTerminalStatusService hardTerminalStatusService = mock(HardTerminalStatusService.class); + + /** 被测 Android 硬件接口控制器。 */ + private final HardApiController controller = new HardApiController(hardManagerService, hardInvoiceService, + hardCarService, hardOrderService, hardOrderSyncService, hardApiService, hardOrderTotalService, + reservationMenuQueryService, verificationRecordService, hardTerminalStatusService); + + /** + * 验证食堂预约部分退款后数量和行金额按剩余份数投影,已全部退款商品不会返回消费机。 + */ + @Test + void getByOrderIdProjectsFulfillableReservationProducts() { + HardOrderEntity order = HardOrderEntity.builder().id(1L).sysType(HardTypeEnum.CANTEEN.getType()) + .orderType(HardOrderTypeEnum.RESERVE.getType()).build(); + HardCarVo remaining = car(10, 5, 1000L); + HardCarVo fullyRefunded = car(2, 0, 200L); + when(hardOrderService.getById(1L)).thenReturn(order); + when(hardCarService.getByOrderId(1L)).thenReturn(List.of(remaining, fullyRefunded)); + + List result = controller.getByOrderId(1L).getData(); + + assertEquals(1, result.size()); + assertEquals(5, result.get(0).getProductCount()); + assertEquals(500L, result.get(0).getTotalPrice()); + assertEquals(10, result.get(0).getOriginalProductCount()); + } + + /** 构造带原购、剩余数量和原行金额的购物车展示对象。 */ + private static HardCarVo car(int originalCount, int effectiveCount, long totalPrice) { + HardCarVo car = new HardCarVo(); + car.setProductCount(originalCount); + car.setOriginalProductCount(originalCount); + car.setEffectiveProductCount(effectiveCount); + car.setTotalPrice(totalPrice); + return car; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImplTest.java index 96c5557e..02bee04a 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImplTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImplTest.java @@ -25,6 +25,7 @@ import com.bsd.pjcloud.hard.api.vo.android.ReservationMenuQueryVo; import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; import com.bsd.pjcloud.hard.service.HardApiService; +import com.bsd.pjcloud.hard.service.HardCarService; import com.bsd.pjcloud.hard.service.HardManagerService; import com.bsd.pjcloud.hard.service.HardOrderService; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; @@ -55,13 +56,14 @@ class ReservationMenuQueryServiceImplTest { private final HardManagerService hardManagerService = mock(HardManagerService.class); private final HardApiService hardApiService = mock(HardApiService.class); private final HardOrderService hardOrderService = mock(HardOrderService.class); + private final HardCarService hardCarService = mock(HardCarService.class); private final RemoteUserService remoteUserService = mock(RemoteUserService.class); private final RemoteDeptService remoteDeptService = mock(RemoteDeptService.class); private final RemoteMchService remoteMchService = mock(RemoteMchService.class); private final RemoteCanteenSpuService remoteCanteenSpuService = mock(RemoteCanteenSpuService.class); private final ReservationMenuQueryServiceImpl service = new ReservationMenuQueryServiceImpl( - hardManagerService, hardApiService, hardOrderService, remoteUserService, remoteDeptService, remoteMchService, - remoteCanteenSpuService); + hardManagerService, hardApiService, hardOrderService, hardCarService, remoteUserService, remoteDeptService, + remoteMchService, remoteCanteenSpuService); @Test void cardQueryUsesTerminalMerchantAndSelfOnlyFilters() { @@ -83,6 +85,8 @@ class ReservationMenuQueryServiceImplTest { assertEquals(200L, query.getMerchantId()); assertEquals(HardOrderTypeEnum.RESERVE.getType(), query.getOrderType()); assertEquals(List.of(HardOrderStatusEnum.PAY.getType()), query.getOrderStatusCodes()); + assertEquals(List.of(HardRefundStatusEnum.NONE.getStatus(), HardRefundStatusEnum.REJECT.getStatus(), + HardRefundStatusEnum.SUCCESS.getStatus()), query.getRefundStatusCodes()); assertTrue(query.getApptIdOrApptRuleIdNotNull()); assertTrue(query.getIsFindCarList()); assertTrue(query.getIsFindApptList()); @@ -167,6 +171,48 @@ class ReservationMenuQueryServiceImplTest { assertEquals(2, result.getDays().get(0).getMeals().get(0).getOrders().get(1).getItems().get(0).getProductCount()); } + /** 验证部分退款后的预约菜单只返回已退款成功后仍可履约的份数,并隐藏已全部退款商品。 */ + @Test + void partialRefundUsesEffectiveProductCount() { + stubTerminal(100L, 200L, "第一食堂"); + stubFaceUser(300L, "王小华", 400L); + HardCarVo remaining = car(501L, "鲜肉包", "/menu/bun.jpg", 10); + remaining.setRefundedProductCount(5); + remaining.setEffectiveProductCount(5); + HardCarVo fullyRefunded = car(502L, "豆浆", "/menu/milk.jpg", 2); + fullyRefunded.setRefundedProductCount(2); + fullyRefunded.setEffectiveProductCount(0); + when(hardOrderService.scopeList(any(), isNull())).thenReturn(List.of( + order(1L, LocalDate.of(2026, 8, 15), "1", "早餐预约", LocalDateTime.of(2026, 8, 14, 8, 0), + remaining, fullyRefunded))); + + ReservationMenuQueryVo.Order result = service.query(faceQuery(100L, 300L)) + .getDays().get(0).getMeals().get(0).getOrders().get(0); + + assertEquals(1, result.getItems().size()); + assertEquals("鲜肉包", result.getItems().get(0).getProductName()); + assertEquals(5, result.getItems().get(0).getProductCount()); + } + + /** 验证一笔订单的商品全部退款后,不再保留空餐段、订单 ID 或核销入口。 */ + @Test + void fullyRefundedOrderIsOmittedFromQuery() { + stubTerminal(100L, 200L, "第一食堂"); + stubFaceUser(300L, "王小华", 400L); + HardCarVo fullyRefunded = car(502L, "豆浆", "/menu/milk.jpg", 2); + fullyRefunded.setRefundedProductCount(2); + fullyRefunded.setEffectiveProductCount(0); + when(hardOrderService.scopeList(any(), isNull())).thenReturn(List.of( + order(1L, LocalDate.of(2026, 8, 15), "1", "早餐预约", LocalDateTime.of(2026, 8, 14, 8, 0), + fullyRefunded))); + + ReservationMenuQueryVo result = service.query(faceQuery(100L, 300L)); + + assertTrue(result.getDays().isEmpty()); + assertEquals(0, result.getTotalDayCount()); + assertEquals(0, result.getTotalMealCount()); + } + /** 验证预约订单商品会批量补充套餐类型与按配置排序的组成明细。 */ @Test void comboMetadataIsMappedToReservationOrderItems() { @@ -271,6 +317,7 @@ class ReservationMenuQueryServiceImplTest { .apptUseDate(LocalDate.now().plusDays(1)) .build(); when(hardOrderService.getById(1L)).thenReturn(order); + when(hardCarService.getByOrderId(1L)).thenReturn(List.of(car(501L, "鲜肉包", null, 1))); when(hardOrderService.handCancelCode(any())).thenReturn(true); assertTrue(service.cancel(cancel(1L))); @@ -280,6 +327,52 @@ class ReservationMenuQueryServiceImplTest { assertEquals(-1L, cancelCaptor.getValue().getHardwareId()); } + /** 验证部分退款成功的已支付订单仍可核销,全部退款仍由订单状态校验拦截。 */ + @Test + void cancelAllowsPartiallyRefundedPaidOrder() { + HardOrderEntity order = HardOrderEntity.builder() + .id(1L) + .userId(300L) + .merchantId(200L) + .orderType(HardOrderTypeEnum.RESERVE.getType()) + .orderStatus(HardOrderStatusEnum.PAY.getType()) + .refundStatus(HardRefundStatusEnum.SUCCESS.getStatus()) + .apptId(10L) + .apptUseDate(LocalDate.now().plusDays(1)) + .build(); + when(hardOrderService.getById(1L)).thenReturn(order); + HardCarVo remaining = car(501L, "鲜肉包", null, 10); + remaining.setEffectiveProductCount(5); + when(hardCarService.getByOrderId(1L)).thenReturn(List.of(remaining)); + when(hardOrderService.handCancelCode(any())).thenReturn(true); + + assertTrue(service.cancel(cancel(1L))); + verify(hardOrderService).handCancelCode(any()); + } + + /** 验证已支付订单如果商品全部退款,即使聚合状态仍为成功也不能被旧终端直接核销。 */ + @Test + void cancelRejectsOrderWithoutFulfillableProduct() { + HardOrderEntity order = HardOrderEntity.builder() + .id(1L) + .userId(300L) + .merchantId(200L) + .orderType(HardOrderTypeEnum.RESERVE.getType()) + .orderStatus(HardOrderStatusEnum.PAY.getType()) + .refundStatus(HardRefundStatusEnum.SUCCESS.getStatus()) + .apptId(10L) + .apptUseDate(LocalDate.now().plusDays(1)) + .build(); + HardCarVo refunded = car(501L, "鲜肉包", null, 10); + refunded.setEffectiveProductCount(0); + when(hardOrderService.getById(1L)).thenReturn(order); + when(hardCarService.getByOrderId(1L)).thenReturn(List.of(refunded)); + + assertThrows(RuntimeException.class, () -> service.cancel(cancel(1L))); + verify(hardOrderService).getById(1L); + verify(hardCarService).getByOrderId(1L); + } + @Test void cancelRejectsOrderDuringRefund() { HardOrderEntity order = HardOrderEntity.builder() diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImplTest.java new file mode 100644 index 00000000..ed1f1d93 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImplTest.java @@ -0,0 +1,89 @@ +package com.bsd.pjcloud.hard.service.impl.order; + +import com.bsd.pjcloud.hard.api.constant.refund.HardSaleStatusEnum; +import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleCarEntity; +import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; +import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleCarMapper; +import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 订单购物车退款数量统计测试,验证原购快照与剩余可履约数量不会互相覆盖。 + */ +class HardAfterSaleCarServiceImplTest { + + /** 售后商品 Mapper,按父购物车和状态返回测试聚合数据。 */ + private final HardAfterSaleCarMapper afterSaleCarMapper = mock(HardAfterSaleCarMapper.class); + + /** 订单 Mapper,本组测试不查询订单,仅用于满足服务构造依赖。 */ + private final HardOrderMapper orderMapper = mock(HardOrderMapper.class); + + /** 被测售后商品服务。 */ + private HardAfterSaleCarServiceImpl service; + + /** 初始化服务并注入 MyBatis-Plus 的售后商品 Mapper。 */ + @BeforeEach + void setUp() { + service = new HardAfterSaleCarServiceImpl(orderMapper); + ReflectionTestUtils.setField(service, "baseMapper", afterSaleCarMapper); + } + + /** + * 验证同一商品存在多行购物车快照时,已退款和申请中份数按行依次分配且原数量保持不变。 + */ + @Test + void fillRefundQuantityStatsDistributesAggregatesWithoutChangingOriginalCount() { + Long parentCarId = 100L; + HardCarVo first = car(501L, 3); + HardCarVo second = car(501L, 2); + when(afterSaleCarMapper.getOccupiedByCarParentIds(List.of(parentCarId), + List.of(HardSaleStatusEnum.COMPLETE.getStatus()))) + .thenReturn(List.of(refundCar(parentCarId, 501L, 4))); + when(afterSaleCarMapper.getOccupiedByCarParentIds(List.of(parentCarId), + List.of(HardSaleStatusEnum.APPLY.getStatus()))) + .thenReturn(List.of(refundCar(parentCarId, 501L, 1))); + + service.fillRefundQuantityStats(parentCarId, List.of(first, second)); + + assertEquals(3, first.getProductCount()); + assertEquals(3, first.getOriginalProductCount()); + assertEquals(3, first.getRefundedProductCount()); + assertEquals(0, first.getApplyingProductCount()); + assertEquals(0, first.getEffectiveProductCount()); + assertEquals(2, second.getProductCount()); + assertEquals(2, second.getOriginalProductCount()); + assertEquals(1, second.getRefundedProductCount()); + assertEquals(1, second.getApplyingProductCount()); + assertEquals(1, second.getEffectiveProductCount()); + verify(afterSaleCarMapper).getOccupiedByCarParentIds(List.of(parentCarId), + List.of(HardSaleStatusEnum.COMPLETE.getStatus())); + verify(afterSaleCarMapper).getOccupiedByCarParentIds(List.of(parentCarId), + List.of(HardSaleStatusEnum.APPLY.getStatus())); + } + + /** 构造指定商品和原购份数的购物车展示对象。 */ + private static HardCarVo car(Long productId, int productCount) { + HardCarVo car = new HardCarVo(); + car.setProductId(productId); + car.setProductCount(productCount); + return car; + } + + /** 构造 Mapper 已按父购物车和商品聚合的售后商品份数。 */ + private static HardAfterSaleCarEntity refundCar(Long parentCarId, Long productId, int productCount) { + HardAfterSaleCarEntity car = new HardAfterSaleCarEntity(); + car.setParentCarId(parentCarId); + car.setProductId(productId); + car.setProductCount(productCount); + return car; + } +} 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 new file mode 100644 index 00000000..6e10bd49 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java @@ -0,0 +1,109 @@ +package com.bsd.pjcloud.hard.service.impl.order; + +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.refund.HardRefundStatusEnum; +import com.bsd.pjcloud.hard.api.dto.hard.HandCancelCodeDto; +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.HardOrderMapper; +import com.bsd.pjcloud.hard.service.HardCarService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 通用二维码核销入口的食堂预约防绕过测试,覆盖仍调用旧接口的消费机版本。 + */ +class HardOrderServiceImplVerificationTest { + + /** 订单 Mapper,用于返回已持有行锁的订单快照。 */ + private final HardOrderMapper hardOrderMapper = mock(HardOrderMapper.class); + + /** 订单购物车服务,用于返回退款成功后的剩余份数。 */ + private final HardCarService hardCarService = mock(HardCarService.class); + + /** 被测订单服务,仅执行真实方法,未涉及的庞大依赖保持空值。 */ + private final HardOrderServiceImpl service = mock(HardOrderServiceImpl.class, CALLS_REAL_METHODS); + + /** + * 为被测服务注入旧核销拒绝路径实际会访问的两个依赖。 + */ + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "baseMapper", hardOrderMapper); + ReflectionTestUtils.setField(service, "hardCarService", hardCarService); + } + + /** + * 验证退款申请中的预约订单在读取用户和调用钱包核销前即被拒绝。 + */ + @Test + void handCancelCodeRejectsReservationDuringRefund() { + HandCancelCodeDto dto = cancelDto(); + when(hardOrderMapper.selectByIdForUpdate(1L)).thenReturn(reservationOrder(HardRefundStatusEnum.APPLY, HardOrderStatusEnum.PAY)); + + RuntimeException exception = assertThrows(RuntimeException.class, () -> service.handCancelCode(dto)); + + assertEquals("该预约订单正在退款,不能核销", exception.getMessage()); + verify(hardCarService, never()).getByOrderId(anyLong()); + } + + /** + * 验证部分退款聚合状态为成功但商品已全部退款时,旧终端也不能继续核销空订单。 + */ + @Test + void handCancelCodeRejectsFullyRefundedReservation() { + HandCancelCodeDto dto = cancelDto(); + when(hardOrderMapper.selectByIdForUpdate(1L)).thenReturn(reservationOrder(HardRefundStatusEnum.SUCCESS, HardOrderStatusEnum.PAY)); + HardCarVo refunded = new HardCarVo(); + refunded.setProductCount(10); + refunded.setEffectiveProductCount(0); + when(hardCarService.getByOrderId(1L)).thenReturn(List.of(refunded)); + + RuntimeException exception = assertThrows(RuntimeException.class, () -> service.handCancelCode(dto)); + + assertEquals("该预约订单商品已全部退款,不能核销", exception.getMessage()); + } + + /** + * 构造旧消费机通用核销接口请求。 + * + * @return 固定订单和设备 ID 的核销参数 + */ + private HandCancelCodeDto cancelDto() { + HandCancelCodeDto dto = new HandCancelCodeDto(); + dto.setOrderId(1L); + dto.setHardwareId(100L); + return dto; + } + + /** + * 构造食堂预约订单快照。 + * + * @param refundStatus 订单退款聚合状态 + * @param orderStatus 订单履约状态 + * @return 可供旧核销入口校验的订单 + */ + private HardOrderEntity reservationOrder(HardRefundStatusEnum refundStatus, HardOrderStatusEnum orderStatus) { + return HardOrderEntity.builder() + .id(1L) + .sysType(HardTypeEnum.CANTEEN.getType()) + .orderType(HardOrderTypeEnum.RESERVE.getType()) + .orderStatus(orderStatus.getType()) + .refundStatus(refundStatus.getStatus()) + .build(); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppRelationSubscriptionBatchSaveDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppRelationSubscriptionBatchSaveDto.java new file mode 100644 index 00000000..f2d472ae --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appmessage/AppRelationSubscriptionBatchSaveDto.java @@ -0,0 +1,21 @@ +package com.bsd.pjcloud.thirdparty.dto.appmessage; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.io.Serializable; + +/** + * App批量保存当前用户一条关系全部事件订阅的请求。 + */ +@Data +public class AppRelationSubscriptionBatchSaveDto implements Serializable { + + /** UPMS具体关系记录ID,服务端会复核其当前归属和有效期。 */ + @NotNull(message = "用户关系ID不能为空") + private Long relationId; + + /** 全部固定事件的目标订阅状态;关闭后不再生成该关系的新收件箱或系统Push。 */ + @NotNull(message = "关系订阅开关不能为空") + private Boolean enabled; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessageController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessageController.java index dde237a5..e8855a75 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessageController.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessageController.java @@ -6,6 +6,7 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.tenant.TenantBroker; import com.bsd.pjcloud.common.security.annotation.Inner; import com.bsd.pjcloud.common.security.util.SecurityUtils; +import com.bsd.pjcloud.thirdparty.dto.appmessage.AppRelationSubscriptionBatchSaveDto; import com.bsd.pjcloud.thirdparty.dto.appmessage.AppRelationSubscriptionSaveDto; import com.bsd.pjcloud.thirdparty.dto.appmessage.AppUserPushSettingSaveDto; import com.bsd.pjcloud.thirdparty.dto.appmessage.MealVerifiedEventDto; @@ -191,6 +192,20 @@ public class ThirdAppMessageController { return R.ok(); } + /** + * 批量保存当前用户一个具体关系的全部固定事件订阅。 + * + * @param dto 具体关系全部事件的目标订阅状态 + * @return 无业务数据成功响应 + */ + @Operation(summary = "批量保存当前用户关系消息订阅") + @ApiCryptoAes + @PostMapping("/app/relation-subscription/all") + public R saveAllRelationSubscriptions(@Valid @RequestBody AppRelationSubscriptionBatchSaveDto dto) { + settingService.saveAllRelationSubscriptions(currentUserId(), dto); + return R.ok(); + } + /** * 返回App展示用固定事件选项。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdAppMessageSettingService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdAppMessageSettingService.java index 8b1559b8..3fc88f73 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdAppMessageSettingService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdAppMessageSettingService.java @@ -1,6 +1,7 @@ package com.bsd.pjcloud.thirdparty.service.appmessage; import com.bsd.pjcloud.thirdparty.dto.appmessage.AppPushPolicySaveDto; +import com.bsd.pjcloud.thirdparty.dto.appmessage.AppRelationSubscriptionBatchSaveDto; import com.bsd.pjcloud.thirdparty.dto.appmessage.AppRelationSubscriptionSaveDto; import com.bsd.pjcloud.thirdparty.dto.appmessage.AppUserPushSettingSaveDto; import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppPushPolicyEntity; @@ -98,12 +99,20 @@ public interface ThirdAppMessageSettingService { void saveRelationSubscription(Long userId, AppRelationSubscriptionSaveDto dto); /** - * 判断具体关系和事件是否已由接收用户主动订阅。 + * 批量保存当前用户一个具体关系的全部固定事件订阅,并复核关系当前归属和有效期。 + * + * @param userId 当前登录用户ID + * @param dto 具体关系全部事件的目标订阅状态 + */ + void saveAllRelationSubscriptions(Long userId, AppRelationSubscriptionBatchSaveDto dto); + + /** + * 判断具体关系和事件是否允许生成关系消息,缺少记录按默认开启处理。 * * @param relationId UPMS具体关系ID * @param recipientUserId 关系消息接收用户ID * @param eventCode 固定事件编码 - * @return 是否开启订阅 + * @return 是否允许生成该关系事件消息 */ boolean isRelationSubscribed(Long relationId, Long recipientUserId, String eventCode); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageSettingServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageSettingServiceImpl.java index f9c8d15c..33dcc91b 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageSettingServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageSettingServiceImpl.java @@ -11,6 +11,7 @@ import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageConstants; import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageEventEnum; import com.bsd.pjcloud.thirdparty.dto.appmessage.AppPushPolicyItemDto; import com.bsd.pjcloud.thirdparty.dto.appmessage.AppPushPolicySaveDto; +import com.bsd.pjcloud.thirdparty.dto.appmessage.AppRelationSubscriptionBatchSaveDto; import com.bsd.pjcloud.thirdparty.dto.appmessage.AppRelationSubscriptionSaveDto; import com.bsd.pjcloud.thirdparty.dto.appmessage.AppUserEventSettingDto; import com.bsd.pjcloud.thirdparty.dto.appmessage.AppUserPushSettingSaveDto; @@ -273,7 +274,7 @@ public class ThirdAppMessageSettingServiceImpl implements ThirdAppMessageSetting } /** - * 查询当前用户有效关系并合并数据库订阅,所有事件缺省为关闭。 + * 查询当前用户有效关系并合并数据库订阅,所有事件缺省为开启。 * * @param userId 当前登录用户ID * @return 具体关系订阅矩阵 @@ -305,7 +306,7 @@ public class ThirdAppMessageSettingServiceImpl implements ThirdAppMessageSetting item.setEventLabel(event.getLabel()); item.setCategory(event.getCategory()); ThirdAppRelationSubscriptionEntity subscription = subscriptionMap.get(subscriptionKey(relation.getRelationId(), event.getCode())); - item.setEnabled(subscription != null && AppMessageConstants.ENABLED.equals(subscription.getEnabled())); + item.setEnabled(subscription == null || AppMessageConstants.ENABLED.equals(subscription.getEnabled())); return item; }).toList()); return vo; @@ -322,14 +323,46 @@ public class ThirdAppMessageSettingServiceImpl implements ThirdAppMessageSetting @Transactional(rollbackFor = Exception.class) public void saveRelationSubscription(Long userId, AppRelationSubscriptionSaveDto dto) { AppMessageEventEnum event = requireEvent(dto.getEventCode()); - SysUserRelationRecipientVo relation = listCurrentRelations(userId).stream() - .filter(item -> item.getRelationId().equals(dto.getRelationId())) - .findFirst().orElseThrow(() -> new IllegalArgumentException("用户关系已失效或不属于当前用户")); + SysUserRelationRecipientVo relation = requireCurrentRelation(userId, dto.getRelationId()); ThirdAppRelationSubscriptionEntity subscription = relationSubscriptionMapper.selectOne( Wrappers.lambdaQuery() .eq(ThirdAppRelationSubscriptionEntity::getRelationId, dto.getRelationId()) .eq(ThirdAppRelationSubscriptionEntity::getEventCode, event.getCode()) .last("limit 1")); + saveRelationSubscriptionRecord(userId, relation, event, dto.getEnabled(), subscription); + } + + /** + * 在一个事务中覆盖当前用户具体关系的全部固定事件订阅。 + * + * @param userId 当前登录用户ID + * @param dto 具体关系全部事件的目标订阅状态 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void saveAllRelationSubscriptions(Long userId, AppRelationSubscriptionBatchSaveDto dto) { + SysUserRelationRecipientVo relation = requireCurrentRelation(userId, dto.getRelationId()); + Map existingMap = relationSubscriptionMapper.selectList( + Wrappers.lambdaQuery() + .eq(ThirdAppRelationSubscriptionEntity::getRelationId, dto.getRelationId())) + .stream().collect(Collectors.toMap(ThirdAppRelationSubscriptionEntity::getEventCode, + Function.identity(), (first, second) -> first)); + for (AppMessageEventEnum event : AppMessageEventEnum.values()) { + saveRelationSubscriptionRecord(userId, relation, event, dto.getEnabled(), existingMap.get(event.getCode())); + } + } + + /** + * 新增或更新一条具体关系事件订阅,已有记录必须仍归属当前用户。 + * + * @param userId 当前登录用户ID + * @param relation 当前仍有效的UPMS关系快照 + * @param event 固定消息事件 + * @param enabled 目标订阅状态 + * @param subscription 已有订阅记录,不存在时为空 + */ + private void saveRelationSubscriptionRecord(Long userId, SysUserRelationRecipientVo relation, AppMessageEventEnum event, + Boolean enabled, ThirdAppRelationSubscriptionEntity subscription) { if (subscription == null) { subscription = new ThirdAppRelationSubscriptionEntity(); subscription.setTenantId(requireTenantId()); @@ -338,7 +371,7 @@ public class ThirdAppMessageSettingServiceImpl implements ThirdAppMessageSetting subscription.setSubjectUserId(relation.getSubjectUserId()); subscription.setRelationCode(relation.getRelationCode()); subscription.setEventCode(event.getCode()); - subscription.setEnabled(flag(dto.getEnabled())); + subscription.setEnabled(flag(enabled)); relationSubscriptionMapper.insert(subscription); } else { if (!userId.equals(subscription.getSubscriberUserId())) { @@ -346,26 +379,28 @@ public class ThirdAppMessageSettingServiceImpl implements ThirdAppMessageSetting } subscription.setSubjectUserId(relation.getSubjectUserId()); subscription.setRelationCode(relation.getRelationCode()); - subscription.setEnabled(flag(dto.getEnabled())); + subscription.setEnabled(flag(enabled)); relationSubscriptionMapper.updateById(subscription); } } /** - * 判断关系接收人是否已主动开启具体事件订阅。 + * 判断关系接收人是否允许接收具体事件,缺少记录表示沿用默认开启。 * * @param relationId 具体关系ID * @param recipientUserId 接收用户ID * @param eventCode 固定事件编码 - * @return 是否开启订阅 + * @return 是否允许生成该关系事件消息 */ @Override public boolean isRelationSubscribed(Long relationId, Long recipientUserId, String eventCode) { - return relationSubscriptionMapper.selectCount(Wrappers.lambdaQuery() - .eq(ThirdAppRelationSubscriptionEntity::getRelationId, relationId) - .eq(ThirdAppRelationSubscriptionEntity::getSubscriberUserId, recipientUserId) - .eq(ThirdAppRelationSubscriptionEntity::getEventCode, eventCode) - .eq(ThirdAppRelationSubscriptionEntity::getEnabled, AppMessageConstants.ENABLED)) > 0; + ThirdAppRelationSubscriptionEntity subscription = relationSubscriptionMapper.selectOne( + Wrappers.lambdaQuery() + .eq(ThirdAppRelationSubscriptionEntity::getRelationId, relationId) + .eq(ThirdAppRelationSubscriptionEntity::getSubscriberUserId, recipientUserId) + .eq(ThirdAppRelationSubscriptionEntity::getEventCode, eventCode) + .last("limit 1")); + return subscription == null || AppMessageConstants.ENABLED.equals(subscription.getEnabled()); } /** @@ -409,6 +444,19 @@ public class ThirdAppMessageSettingServiceImpl implements ThirdAppMessageSetting return result.getData() == null ? List.of() : result.getData(); } + /** + * 查询并返回当前用户仍有效的指定关系,避免失效关系或越权关系被保存订阅。 + * + * @param userId 当前关系接收用户ID + * @param relationId UPMS具体关系ID + * @return 当前仍有效且归属该用户的关系快照 + */ + private SysUserRelationRecipientVo requireCurrentRelation(Long userId, Long relationId) { + return listCurrentRelations(userId).stream() + .filter(item -> item.getRelationId().equals(relationId)) + .findFirst().orElseThrow(() -> new IllegalArgumentException("用户关系已失效或不属于当前用户")); + } + /** * 校验商户请求覆盖且只覆盖全部固定事件。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageSettingServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageSettingServiceImplTest.java new file mode 100644 index 00000000..a9841c3d --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageSettingServiceImplTest.java @@ -0,0 +1,158 @@ +package com.bsd.pjcloud.thirdparty.service.impl.appmessage; + +import com.bsd.pjcloud.admin.api.feign.RemoteUserRelationService; +import com.bsd.pjcloud.admin.api.vo.user.SysUserRelationRecipientVo; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageConstants; +import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageEventEnum; +import com.bsd.pjcloud.thirdparty.dto.appmessage.AppRelationSubscriptionBatchSaveDto; +import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppRelationSubscriptionEntity; +import com.bsd.pjcloud.thirdparty.mapper.appmessage.ThirdAppPushPolicyMapper; +import com.bsd.pjcloud.thirdparty.mapper.appmessage.ThirdAppPushPolicyRelationMapper; +import com.bsd.pjcloud.thirdparty.mapper.appmessage.ThirdAppRelationSubscriptionMapper; +import com.bsd.pjcloud.thirdparty.mapper.appmessage.ThirdAppUserEventSettingMapper; +import com.bsd.pjcloud.thirdparty.mapper.appmessage.ThirdAppUserPushSettingMapper; +import com.bsd.pjcloud.thirdparty.vo.appmessage.AppRelationSubscriptionVo; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * App关系消息订阅默认值和批量保存规则单元测试。 + */ +class ThirdAppMessageSettingServiceImplTest { + + /** 测试租户ID,用于校验批量新增记录继承当前租户上下文。 */ + private static final Long TENANT_ID = 10L; + + /** 关系消息接收用户ID,测试中代表监护人。 */ + private static final Long RECIPIENT_USER_ID = 20L; + + /** 关系事件主体用户ID,测试中代表被监护人。 */ + private static final Long SUBJECT_USER_ID = 30L; + + /** UPMS具体关系记录ID,用于隔离新旧关系订阅。 */ + private static final Long RELATION_ID = 40L; + + /** 每个测试结束后清理线程租户,避免影响同一测试进程中的其他用例。 */ + @AfterEach + void clearTenantContext() { + TenantContextHolder.setTenantId(null); + } + + /** + * 验证有效关系没有任何订阅记录时,全部固定事件都按默认开启返回。 + */ + @Test + void listRelationSubscriptionsShouldDefaultMissingRecordsToEnabled() { + ThirdAppRelationSubscriptionMapper subscriptionMapper = mock(ThirdAppRelationSubscriptionMapper.class); + RemoteUserRelationService relationService = mock(RemoteUserRelationService.class); + when(relationService.listActiveGuardianWardRelations(RECIPIENT_USER_ID)).thenReturn(R.ok(List.of(createRelation()))); + when(subscriptionMapper.selectList(any())).thenReturn(List.of()); + + List subscriptions = newService(subscriptionMapper, relationService) + .listRelationSubscriptions(RECIPIENT_USER_ID); + + assertEquals(1, subscriptions.size()); + assertEquals(AppMessageEventEnum.values().length, subscriptions.get(0).getEventSettings().size()); + assertTrue(subscriptions.get(0).getEventSettings().stream().allMatch(item -> item.getEnabled())); + } + + /** + * 验证消息生成判断在缺少记录时默认开启,同时尊重用户已经保存的显式关闭记录。 + */ + @Test + void isRelationSubscribedShouldHonorDefaultEnabledAndExplicitDisabled() { + ThirdAppRelationSubscriptionMapper subscriptionMapper = mock(ThirdAppRelationSubscriptionMapper.class); + ThirdAppMessageSettingServiceImpl service = newService(subscriptionMapper, mock(RemoteUserRelationService.class)); + when(subscriptionMapper.selectOne(any())).thenReturn(null); + assertTrue(service.isRelationSubscribed(RELATION_ID, RECIPIENT_USER_ID, AppMessageEventEnum.WALLET_RECHARGE.getCode())); + + ThirdAppRelationSubscriptionEntity disabled = new ThirdAppRelationSubscriptionEntity(); + disabled.setEnabled(AppMessageConstants.DISABLED); + when(subscriptionMapper.selectOne(any())).thenReturn(disabled); + assertFalse(service.isRelationSubscribed(RELATION_ID, RECIPIENT_USER_ID, AppMessageEventEnum.WALLET_RECHARGE.getCode())); + } + + /** + * 验证批量关闭会在一次服务事务中为全部固定事件写入当前关系和用户快照。 + */ + @Test + void saveAllRelationSubscriptionsShouldPersistEveryEvent() { + TenantContextHolder.setTenantId(TENANT_ID); + ThirdAppRelationSubscriptionMapper subscriptionMapper = mock(ThirdAppRelationSubscriptionMapper.class); + RemoteUserRelationService relationService = mock(RemoteUserRelationService.class); + when(relationService.listActiveGuardianWardRelations(RECIPIENT_USER_ID)).thenReturn(R.ok(List.of(createRelation()))); + when(subscriptionMapper.selectList(any())).thenReturn(List.of()); + AppRelationSubscriptionBatchSaveDto dto = new AppRelationSubscriptionBatchSaveDto(); + dto.setRelationId(RELATION_ID); + dto.setEnabled(false); + + newService(subscriptionMapper, relationService).saveAllRelationSubscriptions(RECIPIENT_USER_ID, dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ThirdAppRelationSubscriptionEntity.class); + verify(subscriptionMapper, times(AppMessageEventEnum.values().length)).insert(captor.capture()); + Set actualEventCodes = captor.getAllValues().stream() + .peek(item -> { + assertEquals(TENANT_ID, item.getTenantId()); + assertEquals(RELATION_ID, item.getRelationId()); + assertEquals(RECIPIENT_USER_ID, item.getSubscriberUserId()); + assertEquals(SUBJECT_USER_ID, item.getSubjectUserId()); + assertEquals(AppMessageConstants.DISABLED, item.getEnabled()); + }) + .map(ThirdAppRelationSubscriptionEntity::getEventCode) + .collect(Collectors.toSet()); + Set expectedEventCodes = Set.of(AppMessageEventEnum.values()).stream() + .map(AppMessageEventEnum::getCode) + .collect(Collectors.toSet()); + assertEquals(expectedEventCodes, actualEventCodes); + } + + /** + * 创建包含测试关系订阅Mapper和UPMS关系客户端的服务实例,其余依赖保持无行为Mock。 + * + * @param subscriptionMapper 关系订阅持久化Mock + * @param relationService UPMS有效关系查询Mock + * @return 可独立执行关系订阅逻辑的服务实例 + */ + private ThirdAppMessageSettingServiceImpl newService(ThirdAppRelationSubscriptionMapper subscriptionMapper, + RemoteUserRelationService relationService) { + return new ThirdAppMessageSettingServiceImpl( + mock(ThirdAppPushPolicyMapper.class), + mock(ThirdAppPushPolicyRelationMapper.class), + mock(ThirdAppUserPushSettingMapper.class), + mock(ThirdAppUserEventSettingMapper.class), + subscriptionMapper, + relationService); + } + + /** + * 创建当前仍有效的监护关系快照。 + * + * @return 归属测试接收用户的关系快照 + */ + private SysUserRelationRecipientVo createRelation() { + SysUserRelationRecipientVo relation = new SysUserRelationRecipientVo(); + relation.setRelationId(RELATION_ID); + relation.setRecipientUserId(RECIPIENT_USER_ID); + relation.setSubjectUserId(SUBJECT_USER_ID); + relation.setSubjectUserName("测试被监护人"); + relation.setRelationCode("GUARDIAN_WARD"); + relation.setRelationName("监护关系"); + return relation; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java index 45b70862..d7fd3033 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java @@ -136,6 +136,24 @@ public class WalletTransactionController { DataScope dataScope = null; return R.ok(walletTransactionService.scopePageList(page, dto, dataScope)); } + + /** + * 分页查询本人或有效被监护人的 EAT 饭卡流水,具体用户和钱包由服务端校验并重新解析。 + * + * @param page 分页参数 + * @param wardUserId 本人或被监护人用户 ID + * @param dto 收支类型等可选筛选条件 + * @return 目标饭卡的流水分页 + */ + @Operation(summary = "查询本人或被监护人钱包流水", description = "仅允许查询本人或当前有效被监护人的 EAT 饭卡流水") + @ApiCryptoAes + @GetMapping("/app/guardian-ward/page") + public R> guardianWardPage(@ParameterObject Page page, + @RequestParam Long wardUserId, + @ParameterObject WalletTransactionDto dto) { + PjcloudUser user = SecurityUtils.getUser(); + return R.ok(walletTransactionService.pageSelfOrGuardianWardTransactions(user.getId(), wardUserId, page, dto)); + } /* ************************************* end app end ************************************* */ @ApiCryptoAes diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java index 9d1f0b5d..bc9ca188 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java @@ -18,6 +18,18 @@ public interface WalletTransactionService extends IService scopePageList(Page page, WalletTransactionDto dto, DataScope dataScope); + /** + * 分页查询登录用户本人或其有效被监护人的 EAT 饭卡流水。 + * + * @param loginUserId 当前登录用户 ID,作为监护权限校验主体 + * @param targetUserId 目标用户 ID,仅允许本人或当前有效被监护人 + * @param page 分页参数 + * @param dto 收支类型等可选筛选条件;用户和钱包筛选由服务端覆盖 + * @return 目标用户 EAT 饭卡流水分页;未开通饭卡时返回空分页 + */ + Page pageSelfOrGuardianWardTransactions(Long loginUserId, Long targetUserId, + Page page, WalletTransactionDto dto); + /** * 后台充值统计 */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java index 10143153..bb63dc18 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java @@ -18,6 +18,7 @@ import com.bsd.pjcloud.admin.api.entity.SysDept; import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.feign.RemoteDataScopeService; import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; +import com.bsd.pjcloud.admin.api.feign.RemoteUserRelationService; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; import com.bsd.pjcloud.admin.api.utils.DataUtil; import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; @@ -137,6 +138,11 @@ public class WalletTransactionServiceImpl extends ServiceImpl pageSelfOrGuardianWardTransactions(Long loginUserId, Long targetUserId, + Page page, + WalletTransactionDto dto) { + validateSelfOrGuardianWardUser(loginUserId, targetUserId); + WalletManageEntity wallet = walletManageService.getWallet(targetUserId, WalletTypeEnum.EAT); + if (wallet == null) { + page.setRecords(List.of()); + page.setTotal(0L); + return page; + } + dto.setUserId(targetUserId); + dto.setUserIds(null); + dto.setWalletId(wallet.getId()); + return scopePageList(page, dto, null); + } + + /** + * 校验目标用户是否为登录用户本人或其当前有效被监护人。关系服务异常时必须拒绝访问, + * 不能在授权状态未知时降级放行敏感资金流水。 + * + * @param loginUserId 当前登录用户 ID + * @param targetUserId 目标用户 ID + */ + private void validateSelfOrGuardianWardUser(Long loginUserId, Long targetUserId) { + if (targetUserId == null) { + throw new RuntimeException("缺少目标用户信息"); + } + if (Objects.equals(loginUserId, targetUserId)) { + return; + } + R> result; + try { + result = remoteUserRelationService.listActiveGuardianWardUserIds(loginUserId); + } catch (Exception exception) { + throw new RuntimeException("查询监护关系失败", exception); + } + if (result == null || !result.isOk() || result.getData() == null) { + throw new RuntimeException("查询监护关系失败"); + } + if (!result.getData().contains(targetUserId)) { + throw new RuntimeException("无权查看该被监护人的钱包流水"); + } + } + /** * 后台充值统计 */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImplTest.java new file mode 100644 index 00000000..776c6068 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImplTest.java @@ -0,0 +1,165 @@ +package com.bsd.pjcloud.wallet.service.impl; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.feign.RemoteUserRelationService; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum; +import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto; +import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; +import com.bsd.pjcloud.wallet.api.vo.transaction.WalletTransactionVo; +import com.bsd.pjcloud.wallet.mapper.WalletTransactionMapper; +import com.bsd.pjcloud.wallet.service.WalletManageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +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 WalletTransactionServiceImplTest { + + /** 钱包管理服务 Mock,用于按目标用户解析 EAT 饭卡。 */ + @Mock + private WalletManageService walletManageService; + + /** 用户关系远程服务 Mock,用于模拟有效和无效监护授权。 */ + @Mock + private RemoteUserRelationService remoteUserRelationService; + + /** 钱包流水 Mapper Mock,用于核对最终查询条件。 */ + @Mock + private WalletTransactionMapper walletTransactionMapper; + + /** 被测钱包流水服务,其余与当前授权场景无关的依赖由 Mockito 注入空值。 */ + @InjectMocks + private WalletTransactionServiceImpl service; + + /** + * 将 MyBatis-Plus 基类 Mapper 指向当前 Mock,避免启动 Spring 与数据库。 + */ + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "baseMapper", walletTransactionMapper); + } + + /** + * 查询本人时无需调用关系服务,但仍必须按服务端解析出的 EAT 饭卡覆盖客户端钱包 ID。 + */ + @Test + void selfQueryShouldUseResolvedEatWalletWithoutRelationLookup() { + WalletManageEntity wallet = wallet(20L, 1L); + WalletTransactionDto dto = tamperedQuery(); + Page page = new Page<>(1, 10); + Page result = emptyResultPage(); + when(walletManageService.getWallet(1L, WalletTypeEnum.EAT)).thenReturn(wallet); + when(walletTransactionMapper.scopePageList(page, dto, null)).thenReturn(result); + + assertSame(result, service.pageSelfOrGuardianWardTransactions(1L, 1L, page, dto)); + + verify(remoteUserRelationService, never()).listActiveGuardianWardUserIds(any()); + assertEquals(1L, dto.getUserId()); + assertEquals(20L, dto.getWalletId()); + assertNull(dto.getUserIds()); + } + + /** + * 当前有效被监护人应在关系校验通过后返回目标 EAT 饭卡流水。 + */ + @Test + void activeGuardianWardQueryShouldPassAndOverrideClientScope() { + WalletTransactionDto dto = tamperedQuery(); + Page page = new Page<>(1, 10); + Page result = emptyResultPage(); + when(remoteUserRelationService.listActiveGuardianWardUserIds(1L)).thenReturn(R.ok(List.of(2L))); + when(walletManageService.getWallet(2L, WalletTypeEnum.EAT)).thenReturn(wallet(30L, 2L)); + when(walletTransactionMapper.scopePageList(page, dto, null)).thenReturn(result); + + assertSame(result, service.pageSelfOrGuardianWardTransactions(1L, 2L, page, dto)); + + assertEquals(2L, dto.getUserId()); + assertEquals(30L, dto.getWalletId()); + assertNull(dto.getUserIds()); + } + + /** + * 不在当前有效监护名单中的用户必须拒绝,且不能进入钱包或流水查询。 + */ + @Test + void unrelatedUserQueryShouldBeRejectedBeforeWalletLookup() { + when(remoteUserRelationService.listActiveGuardianWardUserIds(1L)).thenReturn(R.ok(List.of(3L))); + + assertThrows(RuntimeException.class, + () -> service.pageSelfOrGuardianWardTransactions(1L, 2L, new Page<>(1, 10), new WalletTransactionDto())); + + verify(walletManageService, never()).getWallet(any(), any(WalletTypeEnum.class)); + verify(walletTransactionMapper, never()).scopePageList(any(), any(), isNull()); + } + + /** + * 关系服务失败时必须按拒绝处理,不能把授权状态未知降级为允许查看。 + */ + @Test + void relationServiceFailureShouldFailClosed() { + when(remoteUserRelationService.listActiveGuardianWardUserIds(1L)).thenReturn(R.failed("关系服务不可用")); + + assertThrows(RuntimeException.class, + () -> service.pageSelfOrGuardianWardTransactions(1L, 2L, new Page<>(1, 10), new WalletTransactionDto())); + + verify(walletManageService, never()).getWallet(any(), any(WalletTypeEnum.class)); + } + + /** + * 构造指定所有人和 ID 的 EAT 饭卡。 + * + * @param walletId 钱包 ID + * @param userId 钱包所有人用户 ID + * @return 钱包实体 + */ + private WalletManageEntity wallet(Long walletId, Long userId) { + WalletManageEntity wallet = new WalletManageEntity(); + wallet.setId(walletId); + wallet.setUserId(userId); + return wallet; + } + + /** + * 构造带越权用户与钱包筛选的请求,用于验证服务端会覆盖敏感范围。 + * + * @return 带客户端伪造范围的钱包流水条件 + */ + private WalletTransactionDto tamperedQuery() { + WalletTransactionDto dto = new WalletTransactionDto(); + dto.setUserId(99L); + dto.setUserIds(List.of(98L, 99L)); + dto.setWalletId(999L); + return dto; + } + + /** + * 构造 Mapper 返回的空分页,避免测试进入与授权无关的展示信息聚合。 + * + * @return 空钱包流水分页 + */ + private Page emptyResultPage() { + Page page = new Page<>(1, 10); + page.setRecords(List.of()); + return page; + } +} diff --git a/prod.env b/prod.env new file mode 100644 index 00000000..b9c5131f --- /dev/null +++ b/prod.env @@ -0,0 +1,4 @@ +MYSQL_HOST=192.168.0.100 +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PWD=bsd.1799