feat:bug修改

This commit is contained in:
dzc committed 2026-08-14 21:37:48 +08:00
1 parent 6d019b10a0
commit fcfe33355b
25 files changed
+1289 -44

No files matched your search

@@ -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;
@@ -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<List<HardCarVo>> getByOrderId(@RequestParam Long orderId) {
return R.ok(hardCarService.getByOrderId(orderId));
List<HardCarVo> carList = hardCarService.getByOrderId(orderId);
HardOrderEntity order = hardwareOrderService.getById(orderId);
return R.ok(projectFulfillableReservationCars(order, carList));
}
/**
* 食堂预约核销只向消费机暴露退款成功后的剩余商品;其他订单继续保持原接口数量和金额语义。
*
* <p>App 订单详情使用独立接口,仍可同时读取原购、已退和剩余数量。这里覆盖旧版食堂、超市消费机
* 共用的二维码核销接口,避免终端继续按原购物车数量出餐。</p>
*
* @param order 订单快照,用于限定食堂预约场景
* @param carList 带退款数量统计的购物车展示对象
* @return 终端可继续履约的商品;非食堂预约订单原样返回
*/
private List<HardCarVo> projectFulfillableReservationCars(HardOrderEntity order, List<HardCarVo> 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")
@@ -7,6 +7,9 @@ import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 售后商品快照 Mapper,负责按订单父购物车和售后状态聚合退款占用量。
*/
@Mapper
public interface HardAfterSaleCarMapper extends PjcloudBaseMapper<HardAfterSaleCarEntity> {
@@ -19,6 +22,16 @@ public interface HardAfterSaleCarMapper extends PjcloudBaseMapper<HardAfterSaleC
*/
List<HardAfterSaleCarEntity> getOccupiedByCarParentId(@Param("carParentId") Long carParentId, @Param("statuses") List<String> statuses);
/**
* 批量查询父购物车在指定售后状态下的商品占用汇总,结果按父购物车和商品 ID 分组。
*
* @param carParentIds 父购物车 ID 集合
* @param statuses 计入占用的售后状态编码集合
* @return 每个父购物车下按商品 ID 聚合的占用明细,无匹配数据时返回空列表
*/
List<HardAfterSaleCarEntity> getOccupiedByCarParentIds(@Param("carParentIds") List<Long> carParentIds,
@Param("statuses") List<String> statuses);
/**
* 查询指定售后单保存的商品快照,审核同意时据此退库存。
*
@@ -27,7 +27,7 @@ import java.util.List;
public interface HardOrderMapper extends PjcloudBaseMapper<HardOrderEntity> {
/**
* 按主键查询并锁定订单行,供退款创建、审核、取消和后台直退串行化同一订单。
* 按主键查询并锁定订单行,供退款创建、审核、取消、后台直退和预约核销串行化同一订单。
*
* @param id 订单主键
* @return 锁定后的订单;不存在时返回 {@code null}
@@ -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<HardAfterSaleCarEntity> {
/**
@@ -42,6 +47,21 @@ public interface HardAfterSaleCarService extends IService<HardAfterSaleCarEntity
*/
List<HardAfterSaleCarEntity> getOccupiedByCarParentId(Long carParentId, List<String> statuses);
/**
* 按售后状态为订单购物车展示对象补充原购、已退、申请中和剩余份数。
*
* @param carParentId 原订单父购物车 ID
* @param carList 原订单购物车展示对象;原始 {@code productCount} 不会被修改
*/
void fillRefundQuantityStats(Long carParentId, List<HardCarVo> carList);
/**
* 批量补充多个订单购物车的退款数量统计,整个列表固定执行两次售后聚合查询。
*
* @param carListsByParentId 父购物车 ID 到原订单购物车展示对象的映射
*/
void fillRefundQuantityStats(Map<Long, List<HardCarVo>> carListsByParentId);
/**
* 查询单个售后申请保存的商品快照。
*
@@ -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<HardCarVo> 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<LocalDate, LinkedHashMap<String, MealAccumulator>> grouped = new TreeMap<>();
for (HardOrderVo order : orders) {
List<OrderItemAccumulator> 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<String, MealAccumulator> meals = grouped.computeIfAbsent(order.getApptUseDate(), ignored -> new LinkedHashMap<>());
MealAccumulator meal = meals.computeIfAbsent(hourType, ignored -> new MealAccumulator(hourType, resolveMealTitle(order, hourType)));
List<OrderItemAccumulator> 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);
}
/**
* 将单个餐段聚合结果转换为终端响应,并保持订单及商品的稳定顺序。
*
@@ -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<HardAfterSaleCarMapper, HardAfterSaleCarEntity> implements HardAfterSaleCarService {
/** 订单 Mapper,用于从订单 ID 解析其父购物车 ID。 */
private final HardOrderMapper hardOrderMapper;
@Override
@@ -68,6 +72,101 @@ public class HardAfterSaleCarServiceImpl extends ServiceImpl<HardAfterSaleCarMap
return baseMapper.getOccupiedByCarParentId(carParentId, statuses);
}
/**
* 已完成退款才减少可履约数量,申请中的份数单独返回供页面提示。
*
* <p>同一商品存在多行历史快照时按购物车顺序分配累计退款份数,避免把商品级汇总重复写入每一行。</p>
*
* @param carParentId 原订单父购物车 ID
* @param carList 原订单购物车展示对象;方法只写入非持久化统计字段
*/
@Override
public void fillRefundQuantityStats(Long carParentId, List<HardCarVo> carList) {
if (carParentId == null || ObjectUtil.isEmpty(carList)) return;
fillRefundQuantityStats(Map.of(carParentId, carList));
}
/**
* 批量统计避免订单列表按每笔订单额外执行两次售后查询;每个购物车仍独立分配商品级累计份数。
*
* @param carListsByParentId 父购物车 ID 到原订单购物车展示对象的映射
*/
@Override
public void fillRefundQuantityStats(Map<Long, List<HardCarVo>> carListsByParentId) {
if (ObjectUtil.isEmpty(carListsByParentId)) return;
List<Long> parentIds = carListsByParentId.keySet().stream().filter(java.util.Objects::nonNull).distinct().toList();
if (parentIds.isEmpty()) return;
Map<Long, Map<Long, Integer>> refundedByParent = quantityMap(parentIds, HardSaleStatusEnum.COMPLETE.getStatus());
Map<Long, Map<Long, Integer>> 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<HardCarVo> carList, Map<Long, Integer> refundedQuantities,
Map<Long, Integer> 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<Long, Map<Long, Integer>> quantityMap(List<Long> carParentIds, String status) {
Map<Long, Map<Long, Integer>> 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<Long, Integer> 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);
}
/**
* 审核与详情展示只读取申请时保存的商品快照,避免依赖已变化的商品主数据。
*
@@ -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<HardCarMapper, HardCarEntity
private final HardParentCarMapper hardParentCarMapper;
private final HardSpuService hardSpuService;
/** 售后商品统计服务,只补充订单商品的只读退款数量,不修改原购物车快照。 */
private final HardAfterSaleCarService hardAfterSaleCarService;
private final RemoteOnlineCanteenSpuService onlineCanteenSpuService;
private final RemoteCanteenSpuService canteenSpuService;
private final RemoteSupermarketUnitSkuService supermarketUnitSkuService;
@@ -137,6 +141,7 @@ public class HardCarServiceImpl extends ServiceImpl<HardCarMapper, HardCarEntity
List<HardCarEntity> list = getByParentCarId(order.getParentCarId());
List<HardCarVo> result = BeanUtils.toBean(list, HardCarVo.class);
fillAppProductDetailMetadata(order.getParentCarId(), result);
hardAfterSaleCarService.fillRefundQuantityStats(order.getParentCarId(), result);
return result;
}
@@ -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<HardOrderMapper, HardOrder
private final HardManagerService hardManagerService;
private final HardCarService hardCarService;
/** 售后商品统计服务,为订单列表购物车补充退款后的只读有效份数。 */
private final HardAfterSaleCarService hardAfterSaleCarService;
private final HardParentCarService hardParentCarService;
@Lazy
private final HardInvoiceService hardInvoiceService;
@@ -346,6 +349,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
RequestContextHolder.setRequestAttributes(requestAttributes);
List<HardCarEntity> carList = hardCarService.getByParentCarId(detail.getParentCarId());
List<HardCarVo> carVoList = BeanUtils.toBean(carList, HardCarVo.class);
hardAfterSaleCarService.fillRefundQuantityStats(detail.getParentCarId(), carVoList);
detail.setCarList(carVoList);
});
// 预约订单
@@ -3415,12 +3419,19 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
return extraPrice;
}
/**
* 核销通用二维码订单;食堂预约订单会在数据库订单行锁内复核履约状态、退款状态和剩余商品。
*
* @param dto 订单、设备及可选 Android 订单号组成的核销参数
* @return 核销完成时返回 {@code true}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean handCancelCode(HandCancelCodeDto dto) {
// 查询订单
HardOrderEntity order = baseMapper.selectById(dto.getOrderId());
// 与退款申请共用订单行锁,避免核销校验通过后并发创建售后单。
HardOrderEntity order = baseMapper.selectByIdForUpdate(dto.getOrderId());
if (ObjectUtil.isEmpty(order)) throw new RuntimeException("订单不存在");
validateCanteenReservationBeforeCancel(order);
// 查询用户
UserInfo userInfo = userService.getInnerByUserId(order.getUserId()).getData();
if (ObjectUtil.isEmpty(userInfo)) throw new RuntimeException("用户不存在");
@@ -3453,6 +3464,57 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
return true;
}
/**
* 校验食堂预约订单是否仍可履约,确保旧终端直接调用通用核销接口时不能绕过退款状态与剩余数量。
*
* <p>申请中的商品仍属于原订单履约资源,但为避免退款与出餐同时成功,整笔订单在申请处理期间暂停核销;
* 部分退款完成后只要仍有剩余商品即可继续核销。其他订单类型保持通用核销接口的历史行为。</p>
*
* @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<HardCarVo> 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<HardOrderMapper, HardOrder
// 购物车
CompletableFuture<Void> findCarListFuture = CompletableFuture.runAsync(() -> {
if (dto.getIsFindCarList()) {
Map<Long, List<HardCarVo>> carListsByParentId = new LinkedHashMap<>();
for (HardOrderVo v : list) {
List<HardCarEntity> carList = hardCarService.getByParentCarId(v.getParentCarId());
List<HardCarVo> carVoList = BeanUtils.toBean(carList, HardCarVo.class);
if (v.getParentCarId() != null) carListsByParentId.put(v.getParentCarId(), carVoList);
v.setCarList(carVoList);
}
hardAfterSaleCarService.fillRefundQuantityStats(carListsByParentId);
}
});
// 预约订单
@@ -84,6 +84,32 @@
group by a.parent_car_id, a.product_id
</select>
<select id="getOccupiedByCarParentIds" resultType="com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleCarEntity">
select min(a.id) as id,
min(a.refund_id) as refundId,
a.parent_car_id as parentCarId,
a.product_id as productId,
max(a.product_name) as productName,
max(a.product_pic) as productPic,
max(a.product_nature) as productNature,
max(a.product_price) as productPrice,
sum(ifnull(a.product_count, 0)) as productCount,
sum(ifnull(a.product_weight, 0)) as productWeight,
sum(ifnull(a.total_price, 0)) as totalPrice
from hard_after_sale_car a
inner join hard_after_sale s on s.id = a.refund_id and s.del_flag = '0'
where a.del_flag = '0'
and a.parent_car_id in
<foreach collection="carParentIds" item="carParentId" open="(" close=")" separator=",">
#{carParentId}
</foreach>
and s.status in
<foreach collection="statuses" item="status" open="(" close=")" separator=",">
#{status}
</foreach>
group by a.parent_car_id, a.product_id
</select>
<select id="getByRefundId" resultType="com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleCarEntity">
select a.id,
a.refund_id,
@@ -3,7 +3,7 @@
<mapper namespace="com.bsd.pjcloud.hard.mapper.order.HardOrderMapper">
<!-- 退款事务使用数据库行锁作为单体/微服务两种部署模式下的最终并发保护。 -->
<!-- 退款与预约核销事务使用同一订单行锁,作为单体/微服务两种部署模式下的最终并发保护。 -->
<select id="selectByIdForUpdate" resultType="com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity">
select *
from hard_order
@@ -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<HardCarVo> 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;
}
}
@@ -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()
@@ -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;
}
}
@@ -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();
}
}
@@ -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;
}
@@ -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<Void> saveAllRelationSubscriptions(@Valid @RequestBody AppRelationSubscriptionBatchSaveDto dto) {
settingService.saveAllRelationSubscriptions(currentUserId(), dto);
return R.ok();
}
/**
* 返回App展示用固定事件选项。
*
@@ -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);
@@ -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.<ThirdAppRelationSubscriptionEntity>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<String, ThirdAppRelationSubscriptionEntity> existingMap = relationSubscriptionMapper.selectList(
Wrappers.<ThirdAppRelationSubscriptionEntity>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.<ThirdAppRelationSubscriptionEntity>lambdaQuery()
.eq(ThirdAppRelationSubscriptionEntity::getRelationId, relationId)
.eq(ThirdAppRelationSubscriptionEntity::getSubscriberUserId, recipientUserId)
.eq(ThirdAppRelationSubscriptionEntity::getEventCode, eventCode)
.eq(ThirdAppRelationSubscriptionEntity::getEnabled, AppMessageConstants.ENABLED)) > 0;
ThirdAppRelationSubscriptionEntity subscription = relationSubscriptionMapper.selectOne(
Wrappers.<ThirdAppRelationSubscriptionEntity>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("用户关系已失效或不属于当前用户"));
}
/**
* 校验商户请求覆盖且只覆盖全部固定事件。
*
@@ -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<AppRelationSubscriptionVo> 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<ThirdAppRelationSubscriptionEntity> captor = ArgumentCaptor.forClass(ThirdAppRelationSubscriptionEntity.class);
verify(subscriptionMapper, times(AppMessageEventEnum.values().length)).insert(captor.capture());
Set<String> 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<String> 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;
}
}
@@ -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<Page<WalletTransactionVo>> guardianWardPage(@ParameterObject Page<WalletTransactionVo> 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
@@ -18,6 +18,18 @@ public interface WalletTransactionService extends IService<WalletTransactionEnti
Page<WalletTransactionVo> scopePageList(Page page, WalletTransactionDto dto, DataScope dataScope);
/**
* 分页查询登录用户本人或其有效被监护人的 EAT 饭卡流水。
*
* @param loginUserId 当前登录用户 ID,作为监护权限校验主体
* @param targetUserId 目标用户 ID,仅允许本人或当前有效被监护人
* @param page 分页参数
* @param dto 收支类型等可选筛选条件;用户和钱包筛选由服务端覆盖
* @return 目标用户 EAT 饭卡流水分页;未开通饭卡时返回空分页
*/
Page<WalletTransactionVo> pageSelfOrGuardianWardTransactions(Long loginUserId, Long targetUserId,
Page<WalletTransactionVo> page, WalletTransactionDto dto);
/**
* 后台充值统计
*/
@@ -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<WalletTransactionM
private final RemoteUserService userService;
private final RemoteDeptService deptService;
/**
* 用户关系远程服务,用于在读取被监护人钱包流水前实时校验监护授权。
*/
private final RemoteUserRelationService remoteUserRelationService;
/**
* 项目统一 Redisson 客户端,用于协调并发扣款、补偿和记录迟到扣款禁止标记。
*/
@@ -794,6 +800,61 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
return list;
}
/**
* 分页查询本人或有效被监护人的 EAT 饭卡流水。目标用户与钱包均由服务端重新解析,
* 避免客户端通过篡改用户 ID、用户集合或钱包 ID 越权读取其他账户流水。
*
* @param loginUserId 当前登录用户 ID
* @param targetUserId 目标用户 ID,仅允许本人或当前有效被监护人
* @param page 分页参数
* @param dto 收支类型等可选筛选条件
* @return 目标饭卡流水分页;未开通 EAT 饭卡时返回空分页
*/
@Override
public Page<WalletTransactionVo> pageSelfOrGuardianWardTransactions(Long loginUserId, Long targetUserId,
Page<WalletTransactionVo> 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<List<Long>> 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("无权查看该被监护人的钱包流水");
}
}
/**
* 后台充值统计
*/
@@ -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<WalletTransactionVo> page = new Page<>(1, 10);
Page<WalletTransactionVo> 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<WalletTransactionVo> page = new Page<>(1, 10);
Page<WalletTransactionVo> 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<WalletTransactionVo> emptyResultPage() {
Page<WalletTransactionVo> page = new Page<>(1, 10);
page.setRecords(List.of());
return page;
}
}
+4
View File
@@ -0,0 +1,4 @@
MYSQL_HOST=192.168.0.100
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PWD=bsd.1799