diff --git a/db/20260827_add_third_mch_fz_relation_enable_fz.sql b/db/20260827_add_third_mch_fz_relation_enable_fz.sql new file mode 100644 index 000000000..fb086bcd9 --- /dev/null +++ b/db/20260827_add_third_mch_fz_relation_enable_fz.sql @@ -0,0 +1,3 @@ +-- 为商户渠道关系增加分账开关;默认开启以保持历史关系原有的分账行为。 +ALTER TABLE `pjcloudx_thirdparty`.`third_mch_fz_relation` + ADD COLUMN `enable_fz` char(1) NOT NULL DEFAULT '1' COMMENT '是否开启分账:0仅提供渠道商户路由,1绑定接收方并执行平台抽佣' AFTER `fz_ratio`; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java index d90891cec..541e4bcfd 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java @@ -18,6 +18,9 @@ public enum HardAfterSaleOperateTypeEnum { /** 商户审核拒绝退款申请。 */ ADMIN_DISAGREE_APPLY("12", "商家拒绝退款"), + /** 后台将人工审核完成的退款冲正并退回审核中。 */ + ADMIN_RETURN_TO_REVIEW("13", "商家将退款退回审核中"), + /** 会员补充退货物流信息。 */ MEMBER_DELIVERY("20", "会员填写退货物流信息,快递公司:{deliveryName},快递单号:{logisticsNo}"), diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishStatisticsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishStatisticsVo.java index 2fbd187d0..cff3371a7 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishStatisticsVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishStatisticsVo.java @@ -3,6 +3,9 @@ package com.bsd.pjcloud.hard.api.vo.order; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import java.util.ArrayList; +import java.util.List; + /** * 用户预约按餐段和菜品快照聚合的统计数据。 */ @@ -18,6 +21,10 @@ public class HardCanteenUserApptDishStatisticsVo { @Schema(description = "菜品名称") private String productName; + /** 下单时固化的菜品图片地址;历史订单未保存图片时为空。 */ + @Schema(description = "菜品图片地址") + private String productPic; + /** 菜品快照的总份数。 */ @Schema(description = "菜品份数") private Long productCount = 0L; @@ -29,4 +36,8 @@ public class HardCanteenUserApptDishStatisticsVo { /** 该菜品快照总价,单位为分。 */ @Schema(description = "菜品总价,单位为分") private Long totalPrice = 0L; + + /** 点过该菜品的用户及各自净份数;按当前 UPMS 权限用户顺序返回。 */ + @Schema(description = "菜品点餐人员明细") + private List users = new ArrayList<>(); } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishUserStatisticsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishUserStatisticsVo.java new file mode 100644 index 000000000..88cd8b4b6 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishUserStatisticsVo.java @@ -0,0 +1,32 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 用户在指定餐段和菜品统计项下的点餐份数。 + */ +@Data +@Schema(description = "食堂用户预约菜品点餐人员统计") +public class HardCanteenUserApptDishUserStatisticsVo { + + /** 系统用户主键,来源于当前操作者数据权限范围内的 UPMS 用户。 */ + @Schema(description = "用户主键") + private Long userId; + + /** 页面展示姓名,依次取姓名、昵称和登录账号。 */ + @Schema(description = "用户姓名") + private String name; + + /** 用户登录账号,用于同名人员识别;未维护时为空。 */ + @Schema(description = "登录账号") + private String username; + + /** 用户当前所属部门名称;未分配部门时为空。 */ + @Schema(description = "部门名称") + private String deptName; + + /** 该用户在当前餐段和菜品统计项下完成退款扣减后的净份数。 */ + @Schema(description = "用户点餐份数") + private Long productCount = 0L; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java index 639db0906..833c0015e 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java @@ -200,6 +200,20 @@ public class HardAfterSaleController { return R.ok(hardAfterSaleService.disagreeRefund(user.getId(), dto)); } + /** + * 将误同意的人工钱包退款退回审核中,并同步冲正钱包入账和退款库存恢复。 + * + * @param id 已完成人工审核的售后单 ID + * @return 资金、库存和售后状态均恢复成功时返回 {@code true} + */ + @PutMapping("/return-to-review") + @Operation(summary = "退回到审核前") + @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_change')") + @Idempotent(expireTime = 10, key = "'/hard-after-sale/return-to-review' + #id", info = "正在处理中,请勿重复操作") + public R returnToReview(@RequestParam Long id) { + return R.ok(hardAfterSaleService.returnToReview(SecurityUtils.getUser().getId(), id)); + } + @Operation(summary = "分页查询", description = "分页查询") @GetMapping("/page") @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_view')") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardCanteenReservationSaleFact.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardCanteenReservationSaleFact.java index 7ba31b460..d063b7780 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardCanteenReservationSaleFact.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardCanteenReservationSaleFact.java @@ -31,6 +31,9 @@ public class HardCanteenReservationSaleFact { /** 下单时保存的商品名称,历史未记录时可为空。 */ private String productName; + /** 下单时保存的商品图片地址,历史未记录时可为空。 */ + private String productPic; + /** 完成退款扣减后的有效商品份数,最小为零。 */ private Integer productCount; diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java index 90a46764b..156b251b3 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java @@ -109,6 +109,15 @@ public interface HardAfterSaleService extends IService { */ Boolean disagreeRefund(Long userId, HardDisagreeDto dto); + /** + * 将人工审核完成的钱包退款冲正并退回申请中,同时恢复退款前的库存占用和订单聚合状态。 + * + * @param userId 当前后台操作用户 ID + * @param id 售后单 ID + * @return 资金、库存和售后状态均已恢复时返回 {@code true} + */ + Boolean returnToReview(Long userId, Long id); + /** * 校验订单是否可以退款 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java index d5d02cba1..e2bae5ad4 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java @@ -294,6 +294,19 @@ public interface HardOrderService extends IService { */ void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce, Long afterSaleId); + /** + * 使用调用方提供的稳定业务号执行售后库存变更,用于同一售后单多次退回、再审核时隔离每轮库存动作。 + * 普通库存协议保持原行为,只有支持幂等的食堂基础 SPU 使用该业务号。 + * + * @param order 原订单 + * @param refundCarList 本次售后商品;为空表示整单商品 + * @param isReduce {@link YesNoEnum#YES} 扣库存,{@link YesNoEnum#NO} 恢复库存 + * @param afterSaleId 售后单 ID,用于校验退款库存上下文 + * @param inventoryBizId 本轮食堂基础 SPU 库存幂等业务号 + */ + void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce, + Long afterSaleId, String inventoryBizId); + /** * 核销硬件二维码 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java index 6958a95b7..f811bc7e7 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java @@ -96,6 +96,12 @@ public class HardAfterSaleServiceImpl extends ServiceImpl 0 + ? RETURN_REVIEW_RESTORE_INVENTORY_BIZ_PREFIX + id + ":" + returnReviewCount : null; + restoreRefundInventory(order, id, inventoryBizId); } getSelf().refund(updateRefundObj.getId()); if (!canteenSpuInventoryOrder) { @@ -858,6 +867,104 @@ public class HardAfterSaleServiceImpl extends ServiceImpl getSelf().returnToReviewInTransaction(userId, id)); + } + + /** + * 在订单锁内冲正人工钱包退款,并恢复退款审核前的库存占用、售后状态和订单聚合状态。 + * 食堂基础 SPU 具备跨服务幂等补偿,先重新占用库存再扣回钱包;普通库存沿用历史接口, + * 先完成可幂等恢复的钱包冲正,再执行库存扣减,失败后可按仍为完成的售后状态继续重试。 + * + * @param userId 当前后台操作用户 ID + * @param id 已完成人工审核的售后单 ID + * @return 资金、库存和售后状态均恢复时返回 {@code true} + */ + @Transactional(rollbackFor = Exception.class) + public Boolean returnToReviewInTransaction(Long userId, Long id) { + HardAfterSaleEntity afterSale = baseMapper.selectById(id); + if (afterSale == null) { + throw new RuntimeException("售后单不存在"); + } + ensureNoAutoRefundTask(id); + long completedReturnCount = countReturnToReviewOperations(id); + if (ObjectUtil.equals(afterSale.getStatus(), HardSaleStatusEnum.APPLY.getStatus()) && completedReturnCount > 0) { + return true; + } + if (!ObjectUtil.equals(afterSale.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus())) { + throw new RuntimeException("仅已完成的人工审核退款可以退回审核中"); + } + + HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(afterSale.getOrderId()); + if (order == null) { + throw new RuntimeException("订单不存在"); + } + HardOrderTypeEnum orderType = HardOrderTypeEnum.valueOfByType(order.getOrderType()); + HardPayTypeEnum payType = HardPayTypeEnum.valueOfByType(order.getPayType()); + if (orderType == null || payType == null || !hardOrderService.isPay(orderType, payType)) { + throw new RuntimeException("仅钱包支付订单支持退回审核,第三方支付退款无法自动扣回"); + } + + long reviewCycle = completedReturnCount + 1; + String inventoryBizId = RETURN_REVIEW_REOCCUPY_INVENTORY_BIZ_PREFIX + id + ":" + reviewCycle; + boolean canteenSpuInventoryOrder = usesCanteenSpuInventory(order); + if (canteenSpuInventoryOrder) { + reoccupyRefundInventory(order, id, inventoryBizId); + } + rollbackWalletRefund(id); + if (!canteenSpuInventoryOrder) { + reoccupyRefundInventory(order, id, inventoryBizId); + } + + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(HardAfterSaleEntity::getId, id) + .eq(HardAfterSaleEntity::getStatus, HardSaleStatusEnum.COMPLETE.getStatus()) + .set(HardAfterSaleEntity::getStatus, HardSaleStatusEnum.APPLY.getStatus()) + .set(HardAfterSaleEntity::getAuditUserId, null) + .set(HardAfterSaleEntity::getAuditTime, null) + .set(HardAfterSaleEntity::getAuditReason, null) + .set(HardAfterSaleEntity::getRefundTime, null); + if (baseMapper.update(null, updateWrapper) != 1) { + throw new RuntimeException("退回售后审核状态失败"); + } + insertAfterSaleLog(id, userId, HardAfterSaleOperateTypeEnum.ADMIN_RETURN_TO_REVIEW.getType(), + HardSaleStatusEnum.COMPLETE.getStatus(), HardSaleStatusEnum.APPLY.getStatus(), + HardAfterSaleOperateTypeEnum.ADMIN_RETURN_TO_REVIEW.getContent()); + recomputeOrderRefundState(order.getId()); + return true; + } + + /** + * 调用 Wallet 冲正原售后单退款,并严格校验跨服务业务响应,远程异常时禁止推进本地售后状态。 + * + * @param afterSaleId 待冲正的 Hardware 售后单 ID + */ + private void rollbackWalletRefund(Long afterSaleId) { + R result = walletRechargeService.rollbackRefund(afterSaleId); + if (result == null || !result.isOk() || !Boolean.TRUE.equals(result.getData())) { + throw new RuntimeException(result == null || ObjectUtil.isEmpty(result.getMsg()) + ? "钱包退款金额扣回失败" : result.getMsg()); + } + } + + /** + * 统计售后单已经完成的退回审核次数,作为同一售后反复审核时库存幂等业务号的稳定轮次。 + * + * @param afterSaleId 售后单 ID + * @return 已持久化的退回审核日志数量 + */ + private long countReturnToReviewOperations(Long afterSaleId) { + return hardAfterSaleLogMapper.selectCount(new LambdaQueryWrapper() + .eq(HardAfterSaleLogEntity::getAfterSaleId, afterSaleId) + .eq(HardAfterSaleLogEntity::getOperateType, + HardAfterSaleOperateTypeEnum.ADMIN_RETURN_TO_REVIEW.getType())); + } + @Override public Boolean disagreeRefund(Long userId, HardDisagreeDto dto) { HardAfterSaleEntity current = baseMapper.selectById(dto.getId()); @@ -1853,15 +1960,40 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId); if (ObjectUtil.isEmpty(refundCars)) { - hardOrderService.fastenerInventory(order, null, YesNoEnum.NO, afterSaleId); + hardOrderService.fastenerInventory(order, null, YesNoEnum.NO, afterSaleId, inventoryBizId); return; } - hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO, afterSaleId); + hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO, afterSaleId, inventoryBizId); + } + + /** + * 重新占用本次退款曾恢复的商品库存。食堂基础 SPU 使用本轮独立业务号保证远程重试不重复扣减, + * 其他商品来源继续使用原库存协议和售后商品快照。 + * + * @param order 原订单 + * @param afterSaleId 退回审核的售后单 ID + * @param inventoryBizId 本轮库存重新占用的幂等业务号 + */ + private void reoccupyRefundInventory(HardOrderEntity order, Long afterSaleId, String inventoryBizId) { + List refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId); + List inventoryCars = ObjectUtil.isEmpty(refundCars) ? null : toInventoryCars(refundCars); + hardOrderService.fastenerInventory(order, inventoryCars, YesNoEnum.YES, afterSaleId, inventoryBizId); } /** diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java index 194e5e930..68f75b1b4 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java @@ -22,6 +22,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptOrderVo; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptVo; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptDailyStatisticsVo; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptDishStatisticsVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptDishUserStatisticsVo; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptMealStatisticsVo; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsSummaryVo; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsVo; @@ -807,6 +808,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ car.setParentCarId(order.getParentCarId()); car.setProductId(fact.getProductId()); car.setProductName(fact.getProductName()); + car.setProductPic(fact.getProductPic()); car.setProductCount(fact.getProductCount()); car.setTotalPrice(fact.getTotalPrice()); return car; @@ -1011,12 +1013,21 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ String productName = normalize(car.getProductName(), "未命名菜品"); String dishKey = hourType + "\u0000" + productName; DishStatisticsAccumulator dish = dishes.computeIfAbsent(dishKey, - key -> new DishStatisticsAccumulator(hourType, productName)); + key -> new DishStatisticsAccumulator(hourType, productName, car.getProductPic())); + // 同餐段同名菜品合并时优先保留首个非空快照图片,兼容早期订单未保存图片的情况。 + if (StrUtil.isBlank(dish.productPic) && StrUtil.isNotBlank(car.getProductPic())) { + dish.productPic = car.getProductPic(); + } dish.productCount += count; dish.totalPrice += number(car.getTotalPrice()); if (orderId != null) { dish.orderIds.add(orderId); } + if (row.getUserId() != null) { + DishUserStatisticsAccumulator user = dish.users.computeIfAbsent(row.getUserId(), + key -> new DishUserStatisticsAccumulator(row)); + user.productCount += count; + } } } } @@ -1124,6 +1135,9 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ /** 下单时固化的菜品名称。 */ private final String productName; + /** 下单时固化的菜品图片地址;同名菜品聚合时保留首个非空值。 */ + private String productPic; + /** 菜品快照份数。 */ private long productCount; @@ -1133,15 +1147,20 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ /** 包含该菜品的去重订单主键。 */ private final Set orderIds = new HashSet<>(); + /** 点过该菜品的用户主键到个人净份数的有序映射。 */ + private final Map users = new LinkedHashMap<>(); + /** * 创建指定餐段和菜品名称的聚合状态。 * * @param hourType 餐段协议编码或历史兼容文案 * @param productName 下单时保存的菜品名称 + * @param productPic 下单时保存的菜品图片地址,历史订单可能为空 */ - private DishStatisticsAccumulator(String hourType, String productName) { + private DishStatisticsAccumulator(String hourType, String productName, String productPic) { this.hourType = hourType; this.productName = productName; + this.productPic = productPic; } /** 转换为菜品统计响应。 */ @@ -1149,9 +1168,57 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ HardCanteenUserApptDishStatisticsVo vo = new HardCanteenUserApptDishStatisticsVo(); vo.setHourType(hourType); vo.setProductName(productName); + vo.setProductPic(productPic); vo.setProductCount(productCount); vo.setOrderCount((long) orderIds.size()); vo.setTotalPrice(totalPrice); + vo.setUsers(users.values().stream().map(DishUserStatisticsAccumulator::toVo).toList()); + return vo; + } + } + + /** 单个用户在指定餐段和菜品统计项下的可变聚合状态。 */ + private static final class DishUserStatisticsAccumulator { + + /** 系统用户主键,来源于当前统计行。 */ + private final Long userId; + + /** 页面展示姓名,来源于当前统计行。 */ + private final String name; + + /** 用户登录账号,来源于当前统计行。 */ + private final String username; + + /** 用户当前所属部门名称,来源于当前统计行。 */ + private final String deptName; + + /** 该用户点选当前菜品并扣除完成退款后的净份数。 */ + private long productCount; + + /** + * 从用户-日期统计行创建个人菜品聚合状态。 + * + * @param row 包含 UPMS 用户摘要的当前统计行 + */ + private DishUserStatisticsAccumulator(HardCanteenUserApptVo row) { + this.userId = row.getUserId(); + this.name = row.getUserName(); + this.username = row.getUsername(); + this.deptName = row.getDeptName(); + } + + /** + * 转换为菜品点餐人员响应。 + * + * @return 当前用户的身份摘要和菜品净份数 + */ + private HardCanteenUserApptDishUserStatisticsVo toVo() { + HardCanteenUserApptDishUserStatisticsVo vo = new HardCanteenUserApptDishUserStatisticsVo(); + vo.setUserId(userId); + vo.setName(name); + vo.setUsername(username); + vo.setDeptName(deptName); + vo.setProductCount(productCount); return vo; } } 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 589566fa7..daa4c1cfe 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 @@ -2145,7 +2145,7 @@ public class HardOrderServiceImpl extends ServiceImpl refundCarList, - YesNoEnum isReduce, Long afterSaleId, boolean validateOnly) { + YesNoEnum isReduce, Long afterSaleId, String inventoryBizId, + boolean validateOnly) { if (ObjectUtil.equals(isReduce, YesNoEnum.NO) && afterSaleId == null) { throw new RuntimeException("食堂菜品退款库存缺少售后单 ID"); } @@ -4223,11 +4225,11 @@ public class HardOrderServiceImpl extends ServiceImpl refundCarList, YesNoEnum isReduce, Long afterSaleId) { + getSelf().fastenerInventory(order, refundCarList, isReduce, afterSaleId, null); + } + + /** + * 按订单商品来源执行库存变更,并允许退款重审流程为食堂基础 SPU 指定本轮独立幂等业务号。 + * 普通库存协议不消费该业务号,继续保持原远程接口和事务顺序。 + * + * @param order 原订单 + * @param refundCarList 本次退款商品;为空表示使用整单购物车 + * @param isReduce 是否扣减库存 + * @param afterSaleId 本次售后单 ID,非退款或历史调用可为空 + * @param inventoryBizId 本轮食堂基础 SPU 库存幂等业务号;为空时沿用历史订单/售后业务号 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce, + Long afterSaleId, String inventoryBizId) { HardTypeEnum hardTypeEnum = HardTypeEnum.valueOfByType(order.getSysType()); HardOrderTypeEnum orderTypeEnum = HardOrderTypeEnum.valueOfByType(order.getOrderType()); if (!ObjectUtil.isAllNotEmpty(hardTypeEnum, orderTypeEnum)) return; @@ -4329,7 +4348,7 @@ public class HardOrderServiceImpl extends ServiceImpl diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplInventoryTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplInventoryTest.java index 1aa7d24cf..343aa99cb 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplInventoryTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplInventoryTest.java @@ -154,6 +154,26 @@ class HardOrderServiceImplInventoryTest { assertEquals(3, request.getItems().get(0).getBeforeStock()); } + /** + * 验证人工退款退回审核时可使用独立业务号重新扣减库存,避免命中首次退款恢复库存的幂等状态。 + */ + @Test + void refundReviewRollbackUsesIndependentInventoryBizId() { + prepareCart(2); + CanteenSpuEntity product = productWithStock(3); + when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(11L))).thenReturn(R.ok(List.of(product))); + when(remoteCanteenSpuService.updateSkuStockIdempotent(any())).thenReturn(R.ok()); + + assertDoesNotThrow(() -> service.fastenerInventory(cashierOrder(), null, YesNoEnum.YES, 500L, + "HARD_AFTER_SALE_REVIEW_REOCCUPY:500:1")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class); + verify(remoteCanteenSpuService).updateSkuStockIdempotent(captor.capture()); + CanteenSpuUpdateStockReqDTO request = captor.getValue(); + assertEquals("HARD_AFTER_SALE_REVIEW_REOCCUPY:500:1", request.getBizId()); + assertEquals(-2, request.getItems().get(0).getIncrCount()); + } + /** * 准备机器收银购物车来源和商品数量。 * diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/app/AppVersionServiceImpl.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/app/AppVersionServiceImpl.java index 34c206f75..1355b2271 100755 --- a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/app/AppVersionServiceImpl.java +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/app/AppVersionServiceImpl.java @@ -39,14 +39,20 @@ public class AppVersionServiceImpl extends ServiceImpl版本记录的平台字段由管理端多选后以英文逗号分隔存储,因此使用精确成员匹配, + * 避免平台编码 {@code 1} 误匹配 {@code 11} 等非目标编码。

+ * + * @param dto 当前版本码、单个平台编码及可选的 App 唯一编码 + * @return 版本码大于当前版本的最新已发布版本;没有可用更新时返回 {@code null} */ @Override public AppVersionEntity lastVersion(AppVersionDto dto) { LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper() .gt(ObjectUtil.isNotEmpty(dto.getVersionCode()), AppVersionEntity::getVersionCode, dto.getVersionCode()) .eq(AppVersionEntity::getStatus, AppVersionStatusEnum.PUBLISHED.getCode()) - .eq(ObjectUtil.isNotEmpty(dto.getPlatform()), AppVersionEntity::getPlatform, dto.getPlatform()) + .apply(ObjectUtil.isNotEmpty(dto.getPlatform()), "FIND_IN_SET({0}, platform) > 0", dto.getPlatform()) .eq(ObjectUtil.isNotEmpty(dto.getAppUniqueCode()), AppVersionEntity::getAppUniqueCode, dto.getAppUniqueCode()) .orderByDesc(AppVersionEntity::getVersionCode) .last("limit 1"); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPayOrderBizTypeEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPayOrderBizTypeEnum.java index be8d8029e..6edf0c2cd 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPayOrderBizTypeEnum.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPayOrderBizTypeEnum.java @@ -13,7 +13,7 @@ import java.util.Arrays; @Getter public enum ThirdPayOrderBizTypeEnum { - MINI_APP_WALLET_RECHARGE("10", "小程序用户钱包充值"), + MINI_APP_WALLET_RECHARGE("10", "APP用户钱包充值"), YXFJ_PAY("20", "云消费机付款码支付"), APP_SHOP_PAY("30", "APP支付-购买商品(有订单)"), GUARDIAN_WARD_WALLET_RECHARGE("40", "监护人代被监护人饭卡充值"), diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdMchFzRelationSaveDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdMchFzRelationSaveDto.java index 146584fd9..e3bb5ed8d 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdMchFzRelationSaveDto.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdMchFzRelationSaveDto.java @@ -36,10 +36,10 @@ public class ThirdMchFzRelationSaveDto { @Size(max = 512, message = "支付宝授权令牌不能超过512个字符") private String appAuthToken; - /** 渠道接收方类型;新增时必填,编辑后变化时必须同时提交完整新标识并创建新关系版本。 */ + /** 渠道接收方类型;开启分账时必填,编辑后变化时必须同时提交完整新标识并创建新关系版本。 */ private String receiverType; - /** 渠道接收方账号、登录号、openId 或卡别名号;编辑时为空表示保留原完整值,非空变化时创建新关系版本。 */ + /** 渠道接收方账号、登录号、openId 或卡别名号;开启分账时必填,编辑时为空表示保留原完整值。 */ @Size(max = 128, message = "接收方标识不能超过128个字符") private String receiverAccount; @@ -47,9 +47,12 @@ public class ThirdMchFzRelationSaveDto { @Size(max = 128, message = "接收方名称不能超过128个字符") private String receiverName; - /** 平台抽佣比例,单位为百分比,最多保留三位小数;新增关系时必填,修改默认关系时可省略。 */ + /** 平台抽佣比例,单位为百分比,最多保留三位小数;开启分账时必须大于0,关闭时允许为空或0。 */ private Double fzRatio; + /** 是否开启分账,1为绑定接收方并抽佣、0为仅使用该关系提供支付渠道商户路由;为空时兼容为1。 */ + private String enableFz; + /** 微信分账关系类型;支付宝关系不使用,变化后原关系会使用新请求号重新绑定。 */ @Size(max = 64, message = "关系类型不能超过64个字符") private String channelRelationType; diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java index 3a0eedd75..9b39b804e 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java @@ -44,14 +44,16 @@ public class ThirdMchFzRelationEntity extends Model { private String accountKey; /** 支付宝第三方代调用授权令牌;仅服务端渠道调用使用,管理接口不得返回完整值。 */ private String appAuthToken; - /** 渠道接收方类型,微信使用 SDK ReceiverType,支付宝支持 userId、loginName、openId、cardAliasNo。 */ + /** 渠道接收方类型,微信使用 SDK ReceiverType,支付宝支持 userId、loginName、openId、cardAliasNo;关闭分账时允许空字符串。 */ private String receiverType; - /** 平台分佣接收账号,发起渠道请求时使用;管理端展示必须脱敏。 */ + /** 平台分佣接收账号,发起渠道请求时使用;管理端展示必须脱敏,关闭分账时允许空字符串。 */ private String receiverAccount; /** 平台分佣接收方名称快照,渠道不要求时允许为空。 */ private String receiverName; /** 当前关系的平台抽佣比例,单位为百分比,范围为0到100,持久化精度为0.001%;新支付和分账金额只读取该关系快照。 */ private Double fzRatio; + /** 是否开启分账,0仅提供支付渠道商户路由、1同时执行接收方绑定和平台抽佣;历史数据默认按1处理。 */ + private String enableFz; /** 渠道关系类型,微信默认 SERVICE_PROVIDER,支付宝关系允许为空。 */ private String channelRelationType; /** 渠道绑定稳定幂等号;支付宝重试必须保持不变,微信用于本地审计。 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdMchFzRelationVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdMchFzRelationVo.java index c07bdfe38..df5de7567 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdMchFzRelationVo.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdMchFzRelationVo.java @@ -26,14 +26,16 @@ public class ThirdMchFzRelationVo { private String accountKey; /** 是否已配置支付宝授权令牌;管理端只接收布尔状态,绝不返回完整令牌。 */ private Boolean appAuthTokenConfigured; - /** 接收方类型。 */ + /** 接收方类型;关闭分账且从未配置时为空。 */ private String receiverType; - /** 脱敏后的接收方标识。 */ + /** 脱敏后的接收方标识;关闭分账且从未配置时为空。 */ private String receiverAccount; /** 接收方展示名称。 */ private String receiverName; /** 当前关系的平台抽佣比例,单位为百分比,最多保留三位小数(0.001%)。 */ private Double fzRatio; + /** 是否开启分账,0表示仅用于支付渠道商户路由,1表示同时执行渠道关系绑定和平台抽佣。 */ + private String enableFz; /** 微信关系类型;支付宝为空。 */ private String channelRelationType; /** 当前关系绑定状态。 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java index 96e19bcb2..214f939fb 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java @@ -23,16 +23,17 @@ public interface ThirdMchFzRelationService { List listForManage(Long mchId, Long paySettingId); /** - * 新增或更新关系管理记录;渠道身份变化时创建新版本,避免覆盖历史支付订单引用的关系事实。 + * 新增或更新关系管理记录;渠道身份变化时创建新版本,相同身份已存在历史关系时恢复原主键。 * * @param dto 管理端关系参数 - * @return 保存后的脱敏关系;身份变化时返回新关系主键 + * @return 保存后的脱敏关系;身份变化时返回新关系主键,相同历史身份恢复时返回原关系主键 */ ThirdMchFzRelationVo saveForManage(ThirdMchFzRelationSaveDto dto); /** * 删除关系;已绑定关系只标记为历史关系,删除默认关系后允许当前支付配置暂时没有默认关系。 - * 新支付在新的默认关系绑定前会被支付前置校验拒绝,历史订单仍使用原关系快照。 + * 支付宝在没有默认关系时按普通支付处理;微信服务商支付仍需默认关系提供子商户路由。 + * 历史订单继续使用原关系快照完成退款或退分账。 * * @param ids 关系主键列表 * @return 全部处理成功时返回 true @@ -54,6 +55,17 @@ public interface ThirdMchFzRelationService { */ void syncDefaultRelations(MchManageVo merchant); + /** + * 查询支付创建时可选的默认支付渠道关系。 + * + *

完全未配置默认关系时返回 {@code null};关闭分账的默认关系只校验渠道归属并提供商户路由; + * 开启分账的默认关系还会严格校验抽佣比例和渠道绑定状态。

+ * + * @param setting 商户支付配置 + * @return 当前默认关系事实;未配置默认关系时返回 {@code null} + */ + ThirdMchFzRelationEntity findBoundDefault(MchPaySettingVo setting); + /** * 获取支付创建时必须使用的默认且已绑定关系;关系缺失或未绑定时直接拒绝支付。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java index 26614ace3..c24397af1 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java @@ -155,9 +155,12 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ model.setSubject(SUBJECT); model.setProductCode(PRODUCT_CODE); model.setTimeExpire(LocalDateTimeUtil.format(context.getExpireTime(), DatePattern.NORM_DATETIME_PATTERN)); - SubMerchant subMerchant = new SubMerchant(); - subMerchant.setMerchantId(aliPaySetting.getSubMchid()); - model.setSubMerchant(subMerchant); + boolean enableFz = context.getFzRelationId() != null; + if (enableFz) { + SubMerchant subMerchant = new SubMerchant(); + subMerchant.setMerchantId(aliPaySetting.getSubMchid()); + model.setSubMerchant(subMerchant); + } if (StrUtil.isNotBlank(identity.getOpenId())) { model.setBuyerOpenId(identity.getOpenId()); } else { @@ -168,8 +171,8 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.getAliPayConfig(); request.setNotifyUrl(aliPayConfig.getBaseCallbackUrl() + aliPayConfig.getPayCallbackUrl()); request.setBizModel(model); - // app_auth_token 仅在商户配置了代调用授权时发送;为空时必须让支付宝按平台默认授权处理。 - if (StrUtil.isNotBlank(aliPaySetting.getAppAuthToken())) { + // 普通支付不携带关系专属授权;直付通仅在配置了代调用授权时发送 token。 + if (enableFz && StrUtil.isNotBlank(aliPaySetting.getAppAuthToken())) { request.putOtherTextParam(APP_AUTH_TOKEN_PARAM, aliPaySetting.getAppAuthToken()); } payOrder.setPayRep(buildRequestSnapshot(context).toString()); @@ -314,20 +317,20 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ } /** - * 校验商户支付宝配置并解析可选 app_auth_token。 + * 校验商户支付宝配置并解析可选 app_auth_token;普通支付允许配置 JSON 为空。 * * @param paySetting 当前商户启用的支付宝支付配置 - * @return 支付宝配置;授权令牌为空时仍允许直付通请求继续,由支付宝返回明确权限结果 + * @return 支付宝配置;无关系配置时返回空对象 */ private MchPaySettingEntity.AliPaySetting parseAliPaySetting(MchPaySettingVo paySetting) { - if (paySetting == null || StrUtil.isBlank(paySetting.getSettingJson())) { - throw new RuntimeException("商户支付宝支付配置不完整"); + if (paySetting == null) { + throw new RuntimeException("商户支付宝支付配置不存在"); + } + if (StrUtil.isBlank(paySetting.getSettingJson())) { + return new MchPaySettingEntity.AliPaySetting(); } MchPaySettingEntity.AliPaySetting setting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - if (setting == null || StrUtil.isBlank(setting.getSubMchid())) { - throw new RuntimeException("商户支付宝直付通二级商户标识未配置"); - } - return setting; + return setting == null ? new MchPaySettingEntity.AliPaySetting() : setting; } /** 聚合支付底层再次校验分佣关系,防止绕过编排层直接创建无关系直付通订单。 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java index 34367ceee..faa7cbcc5 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java @@ -1205,15 +1205,22 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay } /** - * 聚合支付选定平台后解析默认关系,并生成只属于当前子订单的渠道配置快照。 + * 聚合支付选定平台后解析可选默认关系,并生成只属于当前子订单的渠道配置快照。 + * + *

默认关系始终提供渠道商户路由;只有关系明确开启分账时才写入分账关系主键。 + * 未配置默认关系时支付宝按普通支付创建子订单。

* * @param setting 已确认启用的支付配置 * @param context 聚合子订单创建上下文 - * @return 由关系事实生成的支付配置快照 + * @return 默认关系存在时返回渠道配置快照,否则返回原支付配置 */ private MchPaySettingVo applyDefaultPaymentRelation(MchPaySettingVo setting, ThirdAggregatePayCreateContext context) { - ThirdMchFzRelationEntity relation = mchFzRelationService.requireBound(setting); - context.setFzRelationId(relation.getId()); + ThirdMchFzRelationEntity relation = mchFzRelationService.findBoundDefault(setting); + if (relation == null) { + context.setFzRelationId(null); + return setting; + } + context.setFzRelationId(YesNoEnum.NO.getCode().equals(relation.getEnableFz()) ? null : relation.getId()); return mchFzRelationService.createPaymentSettingSnapshot(setting, relation); } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java index 215d0f1cc..d1ef3531f 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java @@ -22,6 +22,7 @@ import com.alipay.api.domain.AlipayTradeSettleConfirmModel; import com.alipay.api.domain.AlipayTradeOrderSettleModel; import com.alipay.api.domain.AlipayTradeOrderSettleQueryModel; import com.alipay.api.domain.OpenApiRoyaltyDetailInfoPojo; +import com.alipay.api.domain.RefundRoyaltyResult; import com.alipay.api.domain.RoyaltyDetail; import com.alipay.api.domain.SettleDetailInfo; import com.alipay.api.domain.SettleInfo; @@ -336,7 +337,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySetting(setting); ThirdPayOrderEntity payOrder = buildOrderCodePayOrder(dto, setting, expireTime); - if (dto.getFzRelationId() != null) { + boolean enableFz = dto.getFzRelationId() != null; + if (enableFz) { ThirdMchFzRelationEntity relation = mchFzRelationService == null ? null : mchFzRelationService.getById(dto.getFzRelationId()); requirePaymentCreateRelation(relation, payOrder, aliPaySetting, setting); @@ -350,13 +352,17 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService model.setSubject(ORDER_CODE_SUBJECT); model.setProductCode(ORDER_CODE_PRODUCT_CODE); model.setTimeExpire(LocalDateTimeUtil.format(expireTime, DatePattern.NORM_DATETIME_PATTERN)); - SubMerchant subMerchant = new SubMerchant(); - subMerchant.setMerchantId(aliPaySetting.getSubMchid()); - model.setSubMerchant(subMerchant); + if (enableFz) { + SubMerchant subMerchant = new SubMerchant(); + subMerchant.setMerchantId(aliPaySetting.getSubMchid()); + model.setSubMerchant(subMerchant); + } payOrder.setPayRep(JSONUtil.toJsonStr(model)); request.setBizModel(model); - putAppAuthToken(request::putOtherTextParam, aliPaySetting); + if (enableFz) { + putAppAuthToken(request::putOtherTextParam, aliPaySetting); + } AlipayTradePrecreateResponse response; try { @@ -414,15 +420,19 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } /** - * app支付 + * 创建支付宝 App 支付订单。 + * + *

只有订单携带已绑定默认关系时才创建直付通请求;没有关系时按平台普通支付创建, + * 不发送二级商户、延迟结算或商户授权参数。

+ * + * @param dto 已由编排层校验的支付参数,金额单位为人民币分 + * @param setting 当前商户启用的支付宝支付配置或关系快照 + * @return 支付宝 SDK 签名订单串;渠道调用异常时返回 {@code null} */ public String appPay(ThirdAppPlusPayDto dto, MchPaySettingVo setting) { LocalDateTime now = LocalDateTime.now(); - MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - if (StrUtil.isBlank(aliPaySetting == null ? null : aliPaySetting.getSubMchid())) { - throw new RuntimeException("支付宝直付通二级商户标识未配置"); - } + MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySetting(setting); ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity(); payOrder.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType()); @@ -439,11 +449,14 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting)); payOrder.setFzRelationId(dto.getFzRelationId()); validateFzRatioAndRelation(dto, setting); - if (dto.getAmount() == null || dto.getAmount() <= 10) { - throw new RuntimeException("支付宝直付通支付金额必须大于10分"); + if (dto.getAmount() == null || dto.getAmount() <= 0) { + throw new RuntimeException("支付宝支付金额必须大于0分"); } boolean enableFz = dto.getFzRelationId() != null; if (enableFz) { + if (dto.getAmount() <= 10) { + throw new RuntimeException("支付宝直付通支付金额必须大于10分"); + } // 上层通常已经通过 requireBound 解析关系;底层再次核对是为了防止内部调用者伪造 // fzRelationId,把其他商户、其他 smid 或尚未绑定的关系带入支付宝支付请求。 ThirdMchFzRelationEntity relation = mchFzRelationService == null @@ -466,12 +479,13 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService model.setSubject("资安信"); // 超时时间15分钟 model.setTimeExpire(LocalDateTimeUtil.format(now.plusMinutes(15), DatePattern.NORM_DATETIME_PATTERN)); + SubMerchant subMerchant = new SubMerchant(); subMerchant.setMerchantId(aliPaySetting.getSubMchid()); model.setSubMerchant(subMerchant); SettleInfo directPaySettleInfo = new SettleInfo(); directPaySettleInfo.setSettlePeriodTime("1d"); - // 支付通必须填写`SettleDetailInfo` + // 直付通延迟结算必须填写默认结算明细,普通支付不能携带该参数。 ArrayList settleDetailInfoList = new ArrayList<>(); SettleDetailInfo defaultSettleDetailInfo = new SettleDetailInfo(); defaultSettleDetailInfo.setTransInType("defaultSettle"); @@ -814,7 +828,10 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService || StrUtil.isBlank(payOrder.getThirdPayNo()) || fzOrder.getPrice() == null) { return; } - if (ObjectUtil.equals(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_REFUNDING.getType())) { + if (ObjectUtil.equals(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_REFUNDING.getType()) + || (ObjectUtil.equals(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType()) + && StrUtil.isNotBlank(fzOrder.getFzRefundNo()))) { + // 支付宝渠道事实可以纠正历史版本因 result_code 缺失造成的本地失败误判;这里只查单,不重提退款。 pollAlipayFzRefund(fzOrder, payOrder); return; } @@ -1114,21 +1131,67 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService ThirdFzOrderEntity update = new ThirdFzOrderEntity(); update.setFzRefundRes(snapshot.toString()); if ("REFUND_SUCCESS".equals(response.getRefundStatus())) { - boolean royaltySuccess = response.getRefundRoyaltys() != null && !response.getRefundRoyaltys().isEmpty() - && response.getRefundRoyaltys().stream().allMatch(item -> "SUCCESS".equals(item.getResultCode())); - update.setStatus(royaltySuccess ? ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType() - : ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType()); - if (!royaltySuccess) { - update.setFzRefundError("支付宝退款成功但退分账结果缺失或失败"); + List refundRoyaltys = response.getRefundRoyaltys(); + if (refundRoyaltys == null || refundRoyaltys.isEmpty()) { + // 普通退款成功不等于退分账成功;明细可能延迟返回,保留处理中等待下一次查询。 + update.setStatus(ThirdFzOrderStatusEnum.FZ_REFUNDING.getType()); + update.setFzRefundError("支付宝退款成功,退分账明细尚未返回"); + } else if (refundRoyaltys.stream().anyMatch(this::isExplicitlyFailedRefundRoyalty)) { + update.setStatus(ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType()); + update.setFzRefundError("支付宝退款成功但退分账明细明确失败"); + } else if (refundRoyaltys.stream().allMatch(this::isCompletedRefundRoyalty)) { + update.setStatus(ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType()); + } else { + update.setStatus(ThirdFzOrderStatusEnum.FZ_REFUNDING.getType()); + update.setFzRefundError("支付宝退款成功,退分账明细尚不完整"); } } else if (!response.isSuccess() && !isAlipayRefundQueryMissing(response) && !isRetryableAlipayError(response.getSubCode())) { update.setStatus(ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType()); update.setFzRefundError(StrUtil.firstNonBlank(response.getSubCode(), response.getCode(), "UNKNOWN")); } - thirdFzOrderService.update(update, new LambdaUpdateWrapper() + LambdaUpdateWrapper condition = new LambdaUpdateWrapper() .eq(ThirdFzOrderEntity::getId, fzOrder.getId()) - .eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_REFUNDING.getType())); + .in(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_REFUNDING.getType(), + ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType()); + // 使用 Wrapper 显式写 NULL,避免实体更新策略忽略空值而残留历史版本的误判错误。 + condition.set(ObjectUtil.equals(update.getStatus(), ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType()), + ThirdFzOrderEntity::getFzRefundError, null); + thirdFzOrderService.update(update, condition); + } + + /** + * 判断支付宝退分账明细是否明确失败。 + * + *

{@code result_code} 在支付宝实际成功响应中可能不返回,只有非空且不是 SUCCESS 时才代表明确失败。

+ * + * @param royalty 支付宝退款查询返回的单条退分账明细 + * @return 明细明确携带失败结果码时返回 {@code true} + */ + private boolean isExplicitlyFailedRefundRoyalty(RefundRoyaltyResult royalty) { + return royalty != null && StrUtil.isNotBlank(royalty.getResultCode()) + && !"SUCCESS".equalsIgnoreCase(royalty.getResultCode()); + } + + /** + * 判断支付宝退分账明细是否已经完整返回。 + * + *

退款主状态为 REFUND_SUCCESS 且存在正数退分账金额即可确认该明细完成; + * {@code result_code} 缺失属于支付宝真实成功响应,不能据此判失败。

+ * + * @param royalty 支付宝退款查询返回的单条退分账明细 + * @return 明细包含有效退款金额且没有明确失败结果码时返回 {@code true} + */ + private boolean isCompletedRefundRoyalty(RefundRoyaltyResult royalty) { + if (royalty == null || StrUtil.isBlank(royalty.getRefundAmount()) + || isExplicitlyFailedRefundRoyalty(royalty)) { + return false; + } + try { + return new BigDecimal(royalty.getRefundAmount()).compareTo(BigDecimal.ZERO) > 0; + } catch (NumberFormatException exception) { + return false; + } } /** @@ -1716,17 +1779,20 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } /** - * 解析当前商户支付宝配置;授权令牌允许为空,账号选择和直付通标识由具体调用继续校验。 + * 解析当前商户支付宝配置;普通支付允许没有关系专属配置。 * * @param setting 当前商户支付配置 - * @return 支付宝商户授权配置 + * @return 支付宝商户授权配置;配置 JSON 为空时返回空配置对象 */ private MchPaySettingEntity.AliPaySetting parseAliPaySetting(MchPaySettingVo setting) { - if (setting == null || StrUtil.isBlank(setting.getSettingJson())) { - throw new RuntimeException("商户支付宝支付配置不完整"); + if (setting == null) { + throw new RuntimeException("商户支付宝支付配置不存在"); + } + if (StrUtil.isBlank(setting.getSettingJson())) { + return new MchPaySettingEntity.AliPaySetting(); } MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - return aliPaySetting; + return aliPaySetting == null ? new MchPaySettingEntity.AliPaySetting() : aliPaySetting; } /** diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java index 44dd31c95..69d40742a 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java @@ -743,25 +743,35 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { } /** - * 校验订单码支付首版允许的业务、平台、金额和商户参数。 - */ - /** - * 解析默认关系并生成订单级渠道配置快照,二级商户与支付宝授权均不得再从支付配置 JSON 读取。 + * 解析可选默认分账关系并生成订单级渠道配置快照。 + * + *

默认关系始终提供渠道商户路由;只有关系明确开启分账时才把关系主键传给渠道服务。 + * 未配置默认关系时不携带渠道关系参数,支付宝按普通支付处理。

* * @param paySetting 已由商户服务确认启用的支付配置 * @param payDto 即将提交渠道服务的内部支付参数 - * @return 由关系事实生成的独立支付配置快照 + * @return 默认关系存在时返回渠道配置快照,否则返回原支付配置 */ private MchPaySettingVo applyDefaultPaymentRelation(MchPaySettingVo paySetting, ThirdAppPlusPayDto payDto) { if (mchFzRelationService == null) { throw new IllegalStateException("支付平台分佣关系服务不可用"); } - com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity relation = mchFzRelationService.requireBound(paySetting); - payDto.setFzRelationId(relation.getId()); + com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity relation = mchFzRelationService.findBoundDefault(paySetting); + if (relation == null) { + payDto.setFzRelationId(null); + payDto.setSubMchid(null); + return paySetting; + } + payDto.setFzRelationId(YesNoEnum.NO.getCode().equals(relation.getEnableFz()) ? null : relation.getId()); payDto.setSubMchid(relation.getChannelSubMchId()); return mchFzRelationService.createPaymentSettingSnapshot(paySetting, relation); } + /** + * 校验订单码支付首版允许的业务、平台、金额和商户参数。 + * + * @param dto 服务端订单码支付请求 + */ private void validateOrderCodePayRequest(ThirdOrderCodePayDto dto) { if (dto == null) { throw new RuntimeException("订单码支付参数不能为空"); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java index 637de9ff7..99265488d 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java @@ -53,10 +53,11 @@ import java.util.Objects; import java.util.Set; /** - * 商户微信/支付宝默认平台分佣关系实现。 + * 商户微信/支付宝可选平台分佣关系实现。 * - *

关系绑定是支付前置条件,但外部渠道调用由 Quartz 的 - * {@code FZ_RELATION_BIND} 任务执行;本服务只负责关系事实物化、渠道请求和结果快照。

+ *

未配置默认关系时支付按普通交易处理;一旦配置关系,渠道绑定仍是启用分账的前置条件。 + * 外部渠道调用由 Quartz 的 {@code FZ_RELATION_BIND} 任务执行,本服务只负责关系事实物化、 + * 渠道请求和结果快照。

*/ @Slf4j @Service @@ -146,7 +147,8 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService * 新增或编辑关系配置。渠道身份变化时创建新关系版本,非身份渠道字段变化时重置原关系并重新绑定。 * *

支付订单会永久引用创建时的关系主键,因此接收方账号、类型、二级商户或渠道应用变化时 - * 不能覆盖原记录;旧记录保留为 {@code SUPERSEDED},新记录使用独立幂等号等待 Quartz 绑定。

+ * 不能覆盖原记录;旧记录保留为 {@code SUPERSEDED},新记录使用独立幂等号等待 Quartz 绑定。 + * 管理员再次新增相同渠道身份时恢复原历史关系主键,避免唯一键冲突和重复渠道绑定。

* * @param dto 管理端关系参数;编辑时接收方标识为空表示保留服务端完整值 * @return 保存后的脱敏关系;身份变化时返回新关系主键 @@ -175,23 +177,27 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService if (existing == null) { throw new IllegalArgumentException("分账关系不存在"); } - if (ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode().equals(existing.getStatus())) { - throw new IllegalArgumentException("历史分账关系不能编辑,请维护当前关系"); - } if (!matchesCurrentSetting(existing, setting)) { throw new IllegalArgumentException("历史渠道配置下的关系不能重新用于当前支付配置"); } - boolean wasDefault = YesNoEnum.YES.getCode().equals(existing.getIsDefault()); - relation = updateManageRelation(tenantId, dto, setting, existing); - inheritDefault = wasDefault && !Objects.equals(existing.getId(), relation.getId()); + if (ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode().equals(existing.getStatus())) { + ThirdMchFzRelationSaveDto effective = normalizeManageEdit(dto, setting, existing); + validateManageReceiver(effective, setting); + validateRelationRatio(effective.getFzRatio(), isProfitSharingEnabled(effective.getEnableFz())); + relation = restoreSupersededRelation(tenantId, buildManageRelation(tenantId, effective, setting)); + } else { + boolean wasDefault = YesNoEnum.YES.getCode().equals(existing.getIsDefault()); + relation = updateManageRelation(tenantId, dto, setting, existing); + inheritDefault = wasDefault && !Objects.equals(existing.getId(), relation.getId()); + } } else { validateManageReceiver(dto, setting); - validateRelationRatio(dto.getFzRatio(), true); + validateRelationRatio(dto.getFzRatio(), isProfitSharingEnabled(dto.getEnableFz())); relation = buildManageRelation(tenantId, dto, setting); try { relationMapper.insert(relation); } catch (DuplicateKeyException exception) { - throw new IllegalArgumentException("相同渠道接收方关系已存在,请直接设为默认"); + relation = restoreSupersededRelation(tenantId, relation); } } // 首条关系由服务端自动成为默认关系,不能把支付可用性建立在前端是否正确提交开关上。 @@ -199,7 +205,7 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService || !hasDefaultRelation(tenantId, dto.getPaySettingId())) { setDefault(relation); } - if (relation.getFzRatio() != null && relation.getFzRatio() > 0 + if (isProfitSharingEnabled(relation) && relation.getFzRatio() != null && relation.getFzRatio() > 0 && !ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(relation.getStatus())) { taskEnqueuer.enqueueRelationBind(tenantId, relation.getId(), LocalDateTime.now(), RELATION_TASK_PRIORITY); } @@ -220,14 +226,14 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService MchPaySettingVo setting, ThirdMchFzRelationEntity existing) { ThirdMchFzRelationSaveDto effective = normalizeManageEdit(dto, setting, existing); validateManageReceiver(effective, setting); - validateRelationRatio(effective.getFzRatio(), false); + validateRelationRatio(effective.getFzRatio(), isProfitSharingEnabled(effective.getEnableFz())); String channelAppId = resolveChannelAppId(setting); if (hasChannelIdentityChanged(existing, effective, channelAppId)) { ThirdMchFzRelationEntity replacement = buildManageRelation(tenantId, effective, setting); try { relationMapper.insert(replacement); } catch (DuplicateKeyException exception) { - throw new IllegalArgumentException("相同渠道接收方关系已存在,请直接设为默认"); + replacement = restoreSupersededRelation(tenantId, replacement); } supersedeRelation(existing); return replacement; @@ -235,6 +241,85 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService return updateCurrentRelation(tenantId, effective, setting, existing); } + /** + * 恢复与新增参数具有相同唯一身份的历史关系,继续复用原主键和已绑定渠道事实。 + * + *

关系唯一键包含支付配置、渠道应用、二级商户和接收方身份。命中非历史关系仍视为重复配置; + * 只有 {@code SUPERSEDED} 可以恢复。接收方名称或微信关系类型变化时生成新的稳定绑定请求号并重新排队, + * 完全相同且曾经绑定成功的关系直接恢复为 {@code BOUND}。

+ * + * @param tenantId 当前租户ID,作为关系查询和状态更新的隔离边界 + * @param requested 本次新增构造的完整关系参数,不会直接插入数据库 + * @return 复用原主键并恢复后的关系内存快照 + */ + private ThirdMchFzRelationEntity restoreSupersededRelation(Long tenantId, ThirdMchFzRelationEntity requested) { + ThirdMchFzRelationEntity existing = relationMapper.selectOne(new LambdaQueryWrapper() + .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) + .eq(ThirdMchFzRelationEntity::getMchId, requested.getMchId()) + .eq(ThirdMchFzRelationEntity::getPaySettingId, requested.getPaySettingId()) + .eq(ThirdMchFzRelationEntity::getPlatformType, requested.getPlatformType()) + .eq(ThirdMchFzRelationEntity::getRelationScene, requested.getRelationScene()) + .eq(ThirdMchFzRelationEntity::getChannelAppId, requested.getChannelAppId()) + .eq(ThirdMchFzRelationEntity::getChannelSubMchId, requested.getChannelSubMchId()) + .eq(ThirdMchFzRelationEntity::getReceiverType, requested.getReceiverType()) + .eq(ThirdMchFzRelationEntity::getReceiverAccount, requested.getReceiverAccount()) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0") + .last("LIMIT 1")); + if (existing == null || !ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode().equals(existing.getStatus())) { + throw new IllegalArgumentException("相同渠道接收方关系已存在,请直接设为默认"); + } + + boolean rebindRequired = !Objects.equals(existing.getReceiverName(), requested.getReceiverName()) + || (WECHAT_TYPE.equals(requested.getPlatformType()) + && !Objects.equals(existing.getChannelRelationType(), requested.getChannelRelationType())); + ThirdMchFzRelationEntity restored = BeanUtil.toBean(existing, ThirdMchFzRelationEntity.class); + restored.setAccountKey(requested.getAccountKey()); + // 管理端不会回显完整授权令牌,新增相同历史关系时留空必须沿用原值,避免恢复后支付宝代调用失效。 + restored.setAppAuthToken(StrUtil.isBlank(requested.getAppAuthToken()) + ? existing.getAppAuthToken() : requested.getAppAuthToken()); + restored.setReceiverName(requested.getReceiverName()); + restored.setFzRatio(requested.getFzRatio()); + restored.setEnableFz(requested.getEnableFz()); + restored.setChannelRelationType(requested.getChannelRelationType()); + boolean canReuseBoundRelation = !rebindRequired && existing.getBoundTime() != null; + restored.setStatus(canReuseBoundRelation + ? ThirdMchFzRelationStatusEnum.BOUND.getCode() + : ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode()); + + LambdaUpdateWrapper update = new LambdaUpdateWrapper() + .eq(ThirdMchFzRelationEntity::getId, existing.getId()) + .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) + .eq(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode()) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0") + .set(ThirdMchFzRelationEntity::getAccountKey, restored.getAccountKey()) + .set(ThirdMchFzRelationEntity::getAppAuthToken, restored.getAppAuthToken()) + .set(ThirdMchFzRelationEntity::getReceiverName, restored.getReceiverName()) + .set(ThirdMchFzRelationEntity::getFzRatio, restored.getFzRatio()) + .set(ThirdMchFzRelationEntity::getEnableFz, restored.getEnableFz()) + .set(ThirdMchFzRelationEntity::getChannelRelationType, restored.getChannelRelationType()) + .set(ThirdMchFzRelationEntity::getStatus, restored.getStatus()); + if (!canReuseBoundRelation) { + restored.setOutRequestNo(newRelationRequestNo(requested.getPlatformType(), existing.getId())); + restored.setBindReq(buildBindRequestSnapshot(restored)); + restored.setBindRes(null); + restored.setBindError(null); + restored.setLastExternalStatus(null); + restored.setLastQueryTime(null); + restored.setBoundTime(null); + update.set(ThirdMchFzRelationEntity::getOutRequestNo, restored.getOutRequestNo()) + .set(ThirdMchFzRelationEntity::getBindReq, restored.getBindReq()) + .set(ThirdMchFzRelationEntity::getBindRes, null) + .set(ThirdMchFzRelationEntity::getBindError, null) + .set(ThirdMchFzRelationEntity::getLastExternalStatus, null) + .set(ThirdMchFzRelationEntity::getLastQueryTime, null) + .set(ThirdMchFzRelationEntity::getBoundTime, null); + } + if (relationMapper.update(null, update) != 1) { + throw new IllegalStateException("历史分账关系状态已变化,请刷新后重试"); + } + return restored; + } + /** * 合并编辑参数与数据库完整值。管理端永远拿不到完整接收账号,因此空账号表示保持原值而不是清空。 * @@ -266,6 +351,8 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService effective.setReceiverName(dto.getReceiverName() == null ? existing.getReceiverName() : StrUtil.trim(dto.getReceiverName())); effective.setFzRatio(dto.getFzRatio() == null ? existing.getFzRatio() : dto.getFzRatio()); + effective.setEnableFz(dto.getEnableFz() == null + ? normalizeEnableFz(existing.getEnableFz()) : normalizeEnableFz(dto.getEnableFz())); if (WECHAT_TYPE.equals(setting.getPlatformType())) { effective.setChannelRelationType(StrUtil.isBlank(dto.getChannelRelationType()) ? existing.getChannelRelationType() : StrUtil.trim(dto.getChannelRelationType())); @@ -315,6 +402,7 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService updated.setAppAuthToken(effective.getAppAuthToken()); updated.setReceiverName(effective.getReceiverName()); updated.setFzRatio(effective.getFzRatio()); + updated.setEnableFz(effective.getEnableFz()); updated.setChannelRelationType(effective.getChannelRelationType()); LambdaUpdateWrapper update = new LambdaUpdateWrapper() .eq(ThirdMchFzRelationEntity::getId, existing.getId()) @@ -324,6 +412,7 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService .set(ThirdMchFzRelationEntity::getAppAuthToken, updated.getAppAuthToken()) .set(ThirdMchFzRelationEntity::getReceiverName, updated.getReceiverName()) .set(ThirdMchFzRelationEntity::getFzRatio, updated.getFzRatio()) + .set(ThirdMchFzRelationEntity::getEnableFz, updated.getEnableFz()) .set(ThirdMchFzRelationEntity::getChannelRelationType, updated.getChannelRelationType()); if (rebindRequired) { updated.setOutRequestNo(newRelationRequestNo(setting.getPlatformType(), existing.getId())); @@ -384,7 +473,8 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService * 删除关系;已绑定关系保留为历史事实,只移出默认候选,避免历史订单无法退分账。 * *

删除默认关系不再强制寻找替代关系。删除后当前支付配置可以暂时没有默认关系, - * 新支付会由支付前置校验拒绝,直到管理端新增并绑定新的默认关系;历史订单仍按原关系快照继续退款和退分账。

+ * 支付宝新订单按普通支付处理;微信服务商支付仍需配置一条提供子商户路由的默认关系。 + * 历史订单继续按原关系快照退款和退分账。

* * @param ids 关系主键 * @return 是否全部处理成功 @@ -439,6 +529,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService if (ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode().equals(relation.getStatus())) { throw new IllegalArgumentException("历史分账关系不能重试,请新增关系后绑定"); } + if (!isProfitSharingEnabled(relation)) { + throw new IllegalArgumentException("当前关系未开启分账,无需绑定接收方"); + } relationMapper.update(null, new LambdaUpdateWrapper() .eq(ThirdMchFzRelationEntity::getId, id) .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) @@ -497,14 +590,14 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService } /** - * 校验支付配置当前默认关系已明确绑定。 + * 查询支付配置当前可选的默认渠道关系;没有默认关系表示按渠道允许的普通支付处理。 * * @param setting 商户支付配置 - * @return 当前支付必须使用的默认且已绑定关系 + * @return 默认关系;关闭分账时不要求绑定,未配置默认关系时返回 {@code null} */ @Override @Transactional(rollbackFor = Exception.class) - public ThirdMchFzRelationEntity requireBound(MchPaySettingVo setting) { + public ThirdMchFzRelationEntity findBoundDefault(MchPaySettingVo setting) { if (!isEligible(setting)) { throw new IllegalStateException("商户支付配置未启用或不支持当前渠道"); } @@ -519,11 +612,30 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService .eq(ThirdMchFzRelationEntity::getDelFlag, "0") .last("LIMIT 1")); if (relation == null) { - throw new IllegalStateException("商户未配置默认支付分账关系"); + return null; } return validatePaymentRelation(relation, setting, tenantId); } + /** + * 严格获取默认且已绑定关系,供明确要求必须分账的内部场景继续使用。 + * + * @param setting 商户支付配置 + * @return 当前默认且已绑定的关系事实 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public ThirdMchFzRelationEntity requireBound(MchPaySettingVo setting) { + ThirdMchFzRelationEntity relation = findBoundDefault(setting); + if (relation == null) { + throw new IllegalStateException("商户未配置默认支付分账关系"); + } + if (!isProfitSharingEnabled(relation)) { + throw new IllegalStateException("商户默认支付关系未开启分账"); + } + return relation; + } + /** * {@inheritDoc} */ @@ -541,9 +653,12 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService snapshot.setSettingJson(JSONUtil.toJsonStr(channel)); } else if (ALIPAY_TYPE.equals(setting.getPlatformType())) { MchPaySettingEntity.AliPaySetting channel = new MchPaySettingEntity.AliPaySetting(); + // 关闭分账的支付宝关系只承担本地配置归属,不得把直付通商户或授权参数带入普通支付。 + if (isProfitSharingEnabled(relation)) { + channel.setAccountKey(relation.getAccountKey()); + channel.setAppAuthToken(relation.getAppAuthToken()); + } channel.setSubMchid(relation.getChannelSubMchId()); - channel.setAccountKey(relation.getAccountKey()); - channel.setAppAuthToken(relation.getAppAuthToken()); snapshot.setSettingJson(JSONUtil.toJsonStr(channel)); } else { throw new IllegalArgumentException("不支持的支付平台关系"); @@ -574,7 +689,11 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService .eq(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode()) .eq(ThirdMchFzRelationEntity::getDelFlag, "0") .last("LIMIT 1")); - return validatePaymentRelation(relation, setting, tenantId); + ThirdMchFzRelationEntity validated = validatePaymentRelation(relation, setting, tenantId); + if (!isProfitSharingEnabled(validated)) { + throw new IllegalStateException("支付平台关系未开启分账"); + } + return validated; } /** @@ -600,6 +719,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService if (relation == null || !"0".equals(relation.getDelFlag()) || ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode().equals(relation.getStatus())) { return true; } + if (!isProfitSharingEnabled(relation)) { + return true; + } if (ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(relation.getStatus())) { return true; } @@ -637,10 +759,11 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService private ThirdMchFzRelationEntity buildManageRelation(Long tenantId, ThirdMchFzRelationSaveDto dto, MchPaySettingVo setting) { ThirdMchFzRelationEntity result = buildChannelRelation(tenantId, dto, setting); - result.setReceiverType(StrUtil.trim(dto.getReceiverType())); - result.setReceiverAccount(StrUtil.trim(dto.getReceiverAccount())); + result.setReceiverType(StrUtil.isBlank(dto.getReceiverType()) ? "" : StrUtil.trim(dto.getReceiverType())); + result.setReceiverAccount(StrUtil.isBlank(dto.getReceiverAccount()) ? "" : StrUtil.trim(dto.getReceiverAccount())); result.setReceiverName(StrUtil.trim(dto.getReceiverName())); - result.setFzRatio(dto.getFzRatio()); + result.setFzRatio(dto.getFzRatio() == null ? 0D : dto.getFzRatio()); + result.setEnableFz(normalizeEnableFz(dto.getEnableFz())); if (WECHAT_TYPE.equals(setting.getPlatformType())) { result.setChannelRelationType(StrUtil.blankToDefault(dto.getChannelRelationType(), ReceiverRelationType.SERVICE_PROVIDER.name())); } @@ -697,6 +820,7 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService .set("receiverType", relation.getReceiverType()) .set("receiverAccount", mask(relation.getReceiverAccount())) .set("receiverName", relation.getReceiverName()) + .set("enableFz", normalizeEnableFz(relation.getEnableFz())) .set("channelSubMchId", relation.getChannelSubMchId()) .set("channelRelationType", relation.getChannelRelationType()) .set("accountKey", relation.getAccountKey()) @@ -707,15 +831,19 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService * 管理端接收方类型、账号及微信关系类型校验。 */ private void validateManageReceiver(ThirdMchFzRelationSaveDto dto, MchPaySettingVo setting) { + boolean profitSharingEnabled = isProfitSharingEnabled(dto.getEnableFz()); String receiverType = StrUtil.trim(dto.getReceiverType()); String account = StrUtil.trim(dto.getReceiverAccount()); - if (StrUtil.isBlank(receiverType) || StrUtil.isBlank(account)) { + if (profitSharingEnabled && (StrUtil.isBlank(receiverType) || StrUtil.isBlank(account))) { throw new IllegalArgumentException("接收方类型和标识不能为空"); } if (StrUtil.isBlank(dto.getChannelSubMchId())) { throw new IllegalArgumentException(WECHAT_TYPE.equals(setting.getPlatformType()) ? "微信子商户号不能为空" : "支付宝直付通二级商户标识不能为空"); } + if (!profitSharingEnabled) { + return; + } if (ALIPAY_TYPE.equals(setting.getPlatformType()) && !ALIPAY_RECEIVER_TYPES.contains(receiverType)) { throw new IllegalArgumentException("支付宝接收方只能是userId、loginName、openId或cardAliasNo"); } @@ -838,6 +966,7 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService vo.setReceiverAccount(mask(relation.getReceiverAccount())); vo.setReceiverName(relation.getReceiverName()); vo.setFzRatio(relation.getFzRatio()); + vo.setEnableFz(normalizeEnableFz(relation.getEnableFz())); vo.setChannelRelationType(relation.getChannelRelationType()); vo.setStatus(relation.getStatus()); vo.setIsDefault(relation.getIsDefault()); @@ -857,7 +986,7 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService } /** - * 校验关系抽佣比例;新增关系必须启用抽佣,已有或自动关系允许零值关闭抽佣。 + * 校验关系抽佣比例;开启分账时必须为正数,关闭分账时允许零值保留纯支付路由。 */ private void validateRelationRatio(Double ratio, boolean required) { if (ratio == null) { @@ -879,15 +1008,20 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService } /** - * 校验关系的当前渠道归属、比例与绑定状态,并在尚未绑定时确保恢复任务存在。 + * 校验关系的当前渠道归属;仅开启分账时继续校验接收方、比例与绑定状态。 */ private ThirdMchFzRelationEntity validatePaymentRelation(ThirdMchFzRelationEntity relation, MchPaySettingVo setting, Long tenantId) { if (relation == null || !matchesCurrentSetting(relation, setting) - || StrUtil.hasBlank(relation.getChannelAppId(), relation.getChannelSubMchId(), - relation.getReceiverType(), relation.getReceiverAccount())) { + || StrUtil.hasBlank(relation.getChannelAppId(), relation.getChannelSubMchId())) { throw new IllegalStateException("支付平台分佣关系与当前商户渠道配置不一致"); } + if (!isProfitSharingEnabled(relation)) { + return relation; + } + if (StrUtil.hasBlank(relation.getReceiverType(), relation.getReceiverAccount())) { + throw new IllegalStateException("支付平台分佣接收方配置不完整"); + } validateRelationRatio(relation.getFzRatio(), true); if (relation.getFzRatio() <= 0) { throw new IllegalStateException("支付平台分佣关系未启用抽佣"); @@ -899,6 +1033,40 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService return relation; } + /** + * 判断关系是否开启分账;滚动发布期间旧实例或旧测试对象没有该字段时按历史启用语义处理。 + * + * @param relation 关系事实 + * @return 仅明确保存为0时返回 {@code false} + */ + private boolean isProfitSharingEnabled(ThirdMchFzRelationEntity relation) { + return relation != null && isProfitSharingEnabled(relation.getEnableFz()); + } + + /** + * 判断管理参数是否开启分账;空值按数据库默认值1处理,保证旧管理端请求向后兼容。 + * + * @param enableFz 是否开启分账编码 + * @return 仅明确保存为0时返回 {@code false} + */ + private boolean isProfitSharingEnabled(String enableFz) { + return !YesNoEnum.NO.getCode().equals(enableFz); + } + + /** + * 规范化是否开启分账编码,拒绝除0和1以外的持久化值。 + * + * @param enableFz 管理端或数据库中的原始编码 + * @return 可直接持久化的0或1,空值返回1 + */ + private String normalizeEnableFz(String enableFz) { + String normalized = StrUtil.blankToDefault(StrUtil.trim(enableFz), YesNoEnum.YES.getCode()); + if (!List.of(YesNoEnum.YES.getCode(), YesNoEnum.NO.getCode()).contains(normalized)) { + throw new IllegalArgumentException("是否开启分账只能为0或1"); + } + return normalized; + } + /** * 微信关系绑定。 */ @@ -1204,7 +1372,10 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService * 脱敏账号。 */ private String mask(String value) { - if (StrUtil.isBlank(value) || value.length() <= 4) { + if (StrUtil.isBlank(value)) { + return null; + } + if (value.length() <= 4) { return "****"; } return value.substring(0, 2) + "****" + value.substring(value.length() - 2); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml index 4a01b1ff2..9f563dd72 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml @@ -74,7 +74,7 @@ r.id AS fz_relation_id, 150 AS priority, COALESCE(r.update_time, r.create_time, #{now}) AS next_run_time FROM third_mch_fz_relation r - WHERE r.del_flag = '0' AND r.is_default = '1' + WHERE r.del_flag = '0' AND r.is_default = '1' AND r.enable_fz = '1' AND r.status IN ('PENDING_BIND', 'BINDING') AND NOT EXISTS ( SELECT 1 FROM third_payment_task t diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java index bcd7f6a69..b0bb818e4 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java @@ -11,6 +11,7 @@ import com.alipay.api.domain.AlipayTradePrecreateModel; import com.alipay.api.domain.AlipayTradeRefundModel; import com.alipay.api.domain.AlipayTradeSettleConfirmModel; import com.alipay.api.domain.OpenApiRoyaltyDetailInfoPojo; +import com.alipay.api.domain.RefundRoyaltyResult; import com.alipay.api.domain.RoyaltyDetail; import com.alipay.api.request.AlipayTradeAppPayRequest; import com.alipay.api.request.AlipayTradeFastpayRefundQueryRequest; @@ -196,6 +197,31 @@ class ThirdAppPlusAliPayServiceImplTest { Assertions.assertFalse(requestCaptor.getValue().getTextParams().containsKey("app_auth_token")); } + /** 无默认关系时 App 支付必须走普通支付宝交易,不携带任何直付通或商户授权参数。 */ + @Test + void appPayShouldCreateOrdinaryOrderWithoutCommissionRelation() throws Exception { + payDto.setFzRelationId(null); + paySetting.setSettingJson(null); + AlipayTradeAppPayResponse response = new AlipayTradeAppPayResponse(); + response.setBody("signed-ordinary-alipay-order"); + when(alipayClient.sdkExecute(any(AlipayTradeAppPayRequest.class))).thenReturn(response); + + String result = service.appPay(payDto, paySetting); + + Assertions.assertEquals("signed-ordinary-alipay-order", result); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeAppPayRequest.class); + verify(alipayClient).sdkExecute(requestCaptor.capture()); + AlipayTradeAppPayRequest request = requestCaptor.getValue(); + AlipayTradeAppPayModel model = (AlipayTradeAppPayModel) request.getBizModel(); + Assertions.assertNull(model.getSubMerchant()); + Assertions.assertNull(model.getSettleInfo()); + Assertions.assertFalse(request.getTextParams().containsKey("app_auth_token")); + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).save(orderCaptor.capture()); + Assertions.assertNull(orderCaptor.getValue().getFzRelationId()); + Assertions.assertEquals(YesNoEnum.NO.getCode(), orderCaptor.getValue().getEnableFz()); + } + /** 非当前默认关系即使已经绑定也不能用于创建新支付,避免内部调用伪造关系主键。 */ @Test void appPayShouldRejectBoundButNonDefaultRelation() throws Exception { @@ -402,6 +428,36 @@ class ThirdAppPlusAliPayServiceImplTest { Assertions.assertTrue(fzOrder.getFzRefundReq().contains("2088000000000001")); } + /** 普通支付宝订单退款不查询关系,也不发送退分账参数。 */ + @Test + void refundShouldOmitRoyaltyParametersForOrdinaryPayment() throws Exception { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + payOrder.setEnableFz(YesNoEnum.NO.getCode()); + payOrder.setFzRelationId(null); + MchPaySettingVo ordinarySetting = new MchPaySettingVo(); + ordinarySetting.setId(paySetting.getId()); + ordinarySetting.setMchId(payDto.getMchId()); + ordinarySetting.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType()); + ordinarySetting.setEnable(YesNoEnum.YES.getCode()); + payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(ordinarySetting)); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + AlipayTradeRefundResponse response = new AlipayTradeRefundResponse(); + response.setCode("10000"); + response.setBody("{\"code\":\"10000\"}"); + when(alipayClient.execute(any(AlipayTradeRefundRequest.class))).thenReturn(response); + ThirdMiniWechatRefundDto refundDto = new ThirdMiniWechatRefundDto(); + refundDto.setAfterSaleId(78L); + refundDto.setRefundPrice(500); + + service.refund(payOrder, refundDto); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeRefundRequest.class); + verify(alipayClient).execute(requestCaptor.capture()); + AlipayTradeRefundModel model = (AlipayTradeRefundModel) requestCaptor.getValue().getBizModel(); + Assertions.assertNull(model.getRefundRoyaltyParameters()); + verify(mchFzRelationService, never()).getById(any()); + } + /** * 验证退款查询找不到原请求时使用已保存的相同退款号和参数重提,受理后继续等待查询终态。 */ @@ -463,6 +519,67 @@ class ThirdAppPlusAliPayServiceImplTest { verify(alipayClient, never()).execute(any(AlipayTradeRefundRequest.class)); } + /** + * 验证支付宝退款查询已成功且返回退分账明细时,即使明细没有 result_code 也必须推进退分账成功。 + */ + @Test + void fzRefundQueryShouldAcceptSuccessfulRoyaltyWithoutResultCode() throws Exception { + ThirdPayOrderEntity payOrder = refundingOrderWithRequest("refund_1787732361123EqtlyENhdQ"); + ThirdFzOrderEntity fzOrder = pendingFzOrder(payOrder); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_REFUNDING.getType()); + fzOrder.setFzRefundNo(payOrder.getRefundNo()); + RefundRoyaltyResult royalty = new RefundRoyaltyResult(); + royalty.setOriTransOut("zaxpay@qq.com"); + royalty.setRefundAmount("0.01"); + royalty.setRoyaltyType("transfer"); + royalty.setTransIn("2088631724809185"); + royalty.setTransOut("2088580178966130"); + AlipayTradeFastpayRefundQueryResponse response = new AlipayTradeFastpayRefundQueryResponse(); + response.setCode("10000"); + response.setMsg("Success"); + response.setRefundStatus("REFUND_SUCCESS"); + response.setRefundAmount("1.00"); + response.setRefundRoyaltys(List.of(royalty)); + when(alipayClient.execute(any(AlipayTradeFastpayRefundQueryRequest.class))).thenReturn(response); + when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.pollDirectPayLifecycle(fzOrder, payOrder); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdFzOrderEntity.class); + verify(thirdFzOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + ThirdFzOrderEntity update = updateCaptor.getValue(); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType(), update.getStatus()); + Assertions.assertNull(update.getFzRefundError()); + Assertions.assertTrue(update.getFzRefundRes().contains("REFUND_SUCCESS")); + Assertions.assertTrue(update.getFzRefundRes().contains("0.01")); + } + + /** + * 验证人工重试历史误判记录时,可以通过支付宝成功事实把本地退分账失败纠正为成功。 + */ + @Test + void fzRefundQueryShouldCorrectHistoricalFalseFailure() throws Exception { + ThirdPayOrderEntity payOrder = refundingOrderWithRequest("refund_historical_false_failure"); + ThirdFzOrderEntity fzOrder = pendingFzOrder(payOrder); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType()); + fzOrder.setFzRefundNo(payOrder.getRefundNo()); + RefundRoyaltyResult royalty = new RefundRoyaltyResult(); + royalty.setRefundAmount("0.01"); + AlipayTradeFastpayRefundQueryResponse response = new AlipayTradeFastpayRefundQueryResponse(); + response.setCode("10000"); + response.setRefundStatus("REFUND_SUCCESS"); + response.setRefundRoyaltys(List.of(royalty)); + when(alipayClient.execute(any(AlipayTradeFastpayRefundQueryRequest.class))).thenReturn(response); + when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.pollDirectPayLifecycle(fzOrder, payOrder); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdFzOrderEntity.class); + verify(thirdFzOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType(), updateCaptor.getValue().getStatus()); + Assertions.assertNull(updateCaptor.getValue().getFzRefundError()); + } + @Test void precreateShouldSetRequiredParametersAndSaveUnpaidOrder() throws Exception { AlipayTradePrecreateResponse response = successPrecreateResponse("https://qr.alipay.com/test-content"); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java index e7f068664..f46a5de00 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java @@ -142,7 +142,10 @@ class ThirdAppPlusServiceImplOrderCodePayTest { lenient().when(valueOperations.setIfAbsent(any(), any(), any())).thenReturn(true); ThirdMchFzRelationEntity boundRelation = new ThirdMchFzRelationEntity(); boundRelation.setId(9001L); - lenient().when(mchFzRelationService.requireBound(any())).thenReturn(boundRelation); + boundRelation.setChannelSubMchId("channel-sub-merchant-1001"); + lenient().when(mchFzRelationService.findBoundDefault(any())).thenReturn(boundRelation); + lenient().when(mchFzRelationService.createPaymentSettingSnapshot(any(), any())) + .thenAnswer(invocation -> invocation.getArgument(0)); AppPlusPayConfig.AliPayConfig aliPayConfig = new AppPlusPayConfig.AliPayConfig(); aliPayConfig.setAppId("2026000000000001"); @@ -188,7 +191,7 @@ class ThirdAppPlusServiceImplOrderCodePayTest { } @Test - void orderCodePayShouldRejectMissingMerchantAndAliPayConfiguration() { + void orderCodePayShouldRejectMissingMerchant() { mockNoExistingOrder(); when(remoteMchService.getById(88L)).thenReturn(R.ok()); Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request)); @@ -197,10 +200,6 @@ class ThirdAppPlusServiceImplOrderCodePayTest { merchant.setPaySettingList(List.of()); Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request)); - merchant = enabledAliPayMerchant(); - merchant.getPaySettingList().get(0).setSettingJson("{}"); - when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); - Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request)); } @Test @@ -227,12 +226,55 @@ class ThirdAppPlusServiceImplOrderCodePayTest { verify(pollingCoordinator).wakeOrderCodePolling(); } + /** 未配置默认关系时支付宝订单码必须按普通支付创建,不写入分账关系主键。 */ + @Test + void orderCodePayShouldCreateOrdinaryAlipayOrderWithoutDefaultRelation() { + mockNoExistingOrder(); + when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); + when(mchFzRelationService.findBoundDefault(any())).thenReturn(null); + ThirdOrderCodePayVo expected = orderCodePayVo("ordinary-pay-no", "ordinary-qr", LocalDateTime.now().plusMinutes(2)); + when(thirdMiniAppAliPayService.precreate(any(), any(), any())).thenReturn(expected); + + ThirdOrderCodePayVo result = service.orderCodePay(request); + + Assertions.assertSame(expected, result); + ArgumentCaptor payDtoCaptor = ArgumentCaptor.forClass(ThirdAppPlusPayDto.class); + verify(thirdMiniAppAliPayService).precreate(payDtoCaptor.capture(), any(), any()); + Assertions.assertNull(payDtoCaptor.getValue().getFzRelationId()); + verify(mchFzRelationService, never()).createPaymentSettingSnapshot(any(), any()); + } + + /** 关闭分账的微信默认关系仍提供sub_mchid,但订单不得携带分账关系主键。 */ + @Test + void orderCodePayShouldUseWechatRoutingRelationWithoutProfitSharing() { + request.setMiniPlatformType(AppPlusPlatformTypeEnum.WECHAT); + merchant = enabledWechatMerchant(); + mockNoExistingOrder(); + when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); + ThirdMchFzRelationEntity routingRelation = new ThirdMchFzRelationEntity(); + routingRelation.setId(9002L); + routingRelation.setChannelSubMchId("wx-routing-sub-1001"); + routingRelation.setEnableFz(YesNoEnum.NO.getCode()); + when(mchFzRelationService.findBoundDefault(any())).thenReturn(routingRelation); + ThirdOrderCodePayVo expected = orderCodePayVo("wechat-routing-pay-no", "wechat-routing-qr", LocalDateTime.now().plusMinutes(2)); + when(thirdAppPlusWechatNativePayService.precreate(any(), any(), any())).thenReturn(expected); + + ThirdOrderCodePayVo result = service.orderCodePay(request); + + Assertions.assertSame(expected, result); + ArgumentCaptor payDtoCaptor = ArgumentCaptor.forClass(ThirdAppPlusPayDto.class); + verify(thirdAppPlusWechatNativePayService).precreate(payDtoCaptor.capture(), any(), any()); + Assertions.assertNull(payDtoCaptor.getValue().getFzRelationId()); + Assertions.assertEquals("wx-routing-sub-1001", payDtoCaptor.getValue().getSubMchid()); + verify(mchFzRelationService).createPaymentSettingSnapshot(any(), org.mockito.ArgumentMatchers.eq(routingRelation)); + } + /** 支付宝订单码启用平台抽佣但关系未绑定时必须拒付,不能继续调用预下单接口。 */ @Test void orderCodePayShouldRejectCommissionWhenRelationNotBound() { mockNoExistingOrder(); when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); - when(mchFzRelationService.requireBound(any())).thenThrow( + when(mchFzRelationService.findBoundDefault(any())).thenThrow( new IllegalStateException("支付平台分佣关系尚未绑定,请稍后重试")); RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request)); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java index 63dbdad9a..f3a06c767 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java @@ -124,6 +124,50 @@ class ThirdMchFzRelationServiceImplTest { verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); } + /** 未配置默认关系表示未启用分账,支付编排应收到空值并继续创建普通支付。 */ + @Test + void findBoundDefaultShouldReturnNullWhenCommissionIsNotConfigured() { + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(null); + + ThirdMchFzRelationEntity result = service.findBoundDefault(wechatSetting(101L, 3D)); + + Assertions.assertNull(result); + verify(taskEnqueuer, never()).ensureRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 默认关系关闭分账时只提供渠道商户路由,不要求正比例或已完成接收方绑定。 */ + @Test + void findBoundDefaultShouldReturnDisabledRoutingRelationWithoutBinding() { + ThirdMchFzRelationEntity routingRelation = pendingWechatRelation(7001L); + routingRelation.setIsDefault(YesNoEnum.YES.getCode()); + routingRelation.setEnableFz(YesNoEnum.NO.getCode()); + routingRelation.setFzRatio(0D); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(routingRelation); + + ThirdMchFzRelationEntity result = service.findBoundDefault(wechatSetting(101L, 3D)); + + Assertions.assertSame(routingRelation, result); + verify(taskEnqueuer, never()).ensureRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 关闭分账的支付宝关系生成普通支付快照,不携带直付通二级商户和授权令牌。 */ + @Test + void createPaymentSettingSnapshotShouldOmitAlipayRelationFieldsWhenDisabled() { + MchPaySettingVo setting = alipaySetting(102L, 5D, "legacy-setting-token"); + ThirdMchFzRelationEntity relation = pendingAlipayRelation(7000L); + relation.setEnableFz(YesNoEnum.NO.getCode()); + + MchPaySettingVo snapshot = service.createPaymentSettingSnapshot(setting, relation); + + MchPaySettingEntity.AliPaySetting channel = JSONUtil.toBean( + snapshot.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + Assertions.assertAll( + () -> Assertions.assertNull(channel.getSubMchid()), + () -> Assertions.assertNull(channel.getAccountKey()), + () -> Assertions.assertNull(channel.getAppAuthToken())); + } + /** 正比例默认关系尚未绑定时支付入口必须拒绝,并只确保稳定任务存在而不重置既有退避。 */ @Test void requireBoundShouldRejectAndEnsureTaskWithoutRearm() { @@ -338,9 +382,111 @@ class ThirdMchFzRelationServiceImplTest { () -> Assertions.assertTrue(outRequestNo.matches("[a-z0-9_]+"))); } - /** - * 删除唯一默认关系时不再强制准备替代关系;已绑定关系转为历史状态,新支付由前置校验阻断。 - */ + /** 新增与历史关系完全相同的配置时应恢复原主键,避免唯一键冲突和重复渠道绑定。 */ + @Test + void saveForManageShouldRestoreIdenticalSupersededRelation() { + MchPaySettingVo setting = wechatSetting(101L, 3D); + ThirdMchFzRelationEntity historical = boundWechatRelation(7013L); + historical.setStatus(ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode()); + historical.setIsDefault(YesNoEnum.NO.getCode()); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.insert(any(ThirdMchFzRelationEntity.class))) + .thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate relation")); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(historical); + when(relationMapper.selectCount(any(Wrapper.class))).thenReturn(1L); + when(relationMapper.update(any(), any())).thenReturn(1); + ThirdMchFzRelationSaveDto dto = manageEditDto(historical); + dto.setId(null); + dto.setReceiverAccount(historical.getReceiverAccount()); + + var result = service.saveForManage(dto); + + Assertions.assertAll( + () -> Assertions.assertEquals(historical.getId(), result.getId()), + () -> Assertions.assertEquals(ThirdMchFzRelationStatusEnum.BOUND.getCode(), result.getStatus()), + () -> Assertions.assertEquals(YesNoEnum.NO.getCode(), result.getIsDefault())); + verify(relationMapper).update(any(), any()); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 管理端直接恢复历史关系时无需重新输入脱敏账号,并应将原关系恢复为默认关系。 */ + @Test + void saveForManageShouldRestoreSupersededRelationAsDefault() { + MchPaySettingVo setting = wechatSetting(101L, 3D); + ThirdMchFzRelationEntity historical = boundWechatRelation(7014L); + historical.setStatus(ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode()); + historical.setIsDefault(YesNoEnum.NO.getCode()); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(historical, historical); + when(relationMapper.update(any(), any())).thenReturn(1); + ThirdMchFzRelationSaveDto dto = manageEditDto(historical); + dto.setIsDefault(YesNoEnum.YES.getCode()); + + var result = service.saveForManage(dto); + + Assertions.assertAll( + () -> Assertions.assertEquals(historical.getId(), result.getId()), + () -> Assertions.assertEquals(ThirdMchFzRelationStatusEnum.BOUND.getCode(), result.getStatus()), + () -> Assertions.assertEquals(YesNoEnum.YES.getCode(), result.getIsDefault())); + verify(relationMapper, times(3)).update(any(), any()); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 新增相同支付宝历史关系时,管理端未提交完整授权令牌也必须保留服务端原值。 */ + @Test + void saveForManageShouldPreserveAlipayTokenWhenRestoringSupersededRelation() { + MchPaySettingVo setting = alipaySetting(102L, 5D, "legacy-setting-token"); + ThirdMchFzRelationEntity historical = pendingAlipayRelation(7015L); + historical.setStatus(ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode()); + historical.setIsDefault(YesNoEnum.NO.getCode()); + historical.setBoundTime(LocalDateTime.now().minusMinutes(1)); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.insert(any(ThirdMchFzRelationEntity.class))) + .thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate relation")); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(historical); + when(relationMapper.selectCount(any(Wrapper.class))).thenReturn(1L); + when(relationMapper.update(any(), any())).thenReturn(1); + ThirdMchFzRelationSaveDto dto = manageEditDto(historical); + dto.setId(null); + dto.setReceiverAccount(historical.getReceiverAccount()); + dto.setAppAuthToken(null); + + var result = service.saveForManage(dto); + + Assertions.assertAll( + () -> Assertions.assertEquals(historical.getId(), result.getId()), + () -> Assertions.assertEquals(ThirdMchFzRelationStatusEnum.BOUND.getCode(), result.getStatus()), + () -> Assertions.assertTrue(result.getAppAuthTokenConfigured())); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 新建关闭分账的关系仅保存支付路由,不创建渠道接收方绑定任务。 */ + @Test + void saveForManageShouldNotEnqueueBindingWhenProfitSharingDisabled() { + MchPaySettingVo setting = wechatSetting(101L, 3D); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.selectCount(any(Wrapper.class))).thenReturn(0L); + ThirdMchFzRelationSaveDto dto = new ThirdMchFzRelationSaveDto(); + dto.setMchId(MCH_ID); + dto.setPaySettingId(setting.getId()); + dto.setChannelSubMchId("wx-sub-1001"); + dto.setFzRatio(0D); + dto.setEnableFz(YesNoEnum.NO.getCode()); + ArgumentCaptor relationCaptor = ArgumentCaptor.forClass(ThirdMchFzRelationEntity.class); + when(relationMapper.insert(relationCaptor.capture())).thenReturn(1); + + var result = service.saveForManage(dto); + + Assertions.assertAll( + () -> Assertions.assertEquals(YesNoEnum.NO.getCode(), result.getEnableFz()), + () -> Assertions.assertEquals(0D, result.getFzRatio()), + () -> Assertions.assertEquals("wx-sub-1001", result.getChannelSubMchId()), + () -> Assertions.assertEquals("", relationCaptor.getValue().getReceiverType()), + () -> Assertions.assertEquals("", relationCaptor.getValue().getReceiverAccount())); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 删除唯一默认关系时不再强制准备替代关系;已绑定关系仅转为历史状态。 */ @Test void removeForManageShouldAllowDeletingDefaultWithoutReplacement() { ThirdMchFzRelationEntity existing = boundWechatRelation(7010L); @@ -402,6 +548,7 @@ class ThirdMchFzRelationServiceImplTest { relation.setReceiverAccount("1900000002"); relation.setReceiverName("平台服务商"); relation.setFzRatio(3D); + relation.setEnableFz(YesNoEnum.YES.getCode()); relation.setChannelRelationType(ReceiverRelationType.SERVICE_PROVIDER.name()); relation.setStatus(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode()); relation.setDelFlag("0"); @@ -432,6 +579,7 @@ class ThirdMchFzRelationServiceImplTest { dto.setReceiverAccount(""); dto.setReceiverName(relation.getReceiverName()); dto.setFzRatio(relation.getFzRatio()); + dto.setEnableFz(relation.getEnableFz()); dto.setChannelRelationType(relation.getChannelRelationType()); dto.setIsDefault(YesNoEnum.NO.getCode()); return dto; @@ -451,6 +599,7 @@ class ThirdMchFzRelationServiceImplTest { relation.setReceiverAccount("2088000000000001"); relation.setReceiverName("平台支付宝账号"); relation.setFzRatio(5D); + relation.setEnableFz(YesNoEnum.YES.getCode()); relation.setAppAuthToken("merchant-auth-token"); relation.setOutRequestNo("alirel_7003"); relation.setStatus(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode()); diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletRechargeService.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletRechargeService.java index 5b181214e..9416eb5da 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletRechargeService.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletRechargeService.java @@ -38,6 +38,16 @@ public interface RemoteWalletRechargeService { @PostMapping("/wallet-recharge/refund") R refund(@RequestBody WalletRechargeRefundDto dto); + /** + * 冲正人工审核退款,将已入账金额从原钱包强制扣回;重复请求按钱包退款状态幂等返回。 + * + * @param afterSaleId Hardware 售后单 ID + * @return 钱包退款已冲正时返回 {@code true} + */ + @NoToken + @PostMapping("/wallet-recharge/refund/rollback/{afterSaleId}") + R rollbackRefund(@PathVariable("afterSaleId") Long afterSaleId); + /** * 按售后单 ID 查询已经持久化的退款记录,用于 Wallet 已到账但 Hardware 本地事务回滚后的恢复判断。 * diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeController.java index 2e5d61116..00cbc2430 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeController.java @@ -61,6 +61,19 @@ public class WalletRechargeController { return R.ok(walletRechargeService.refund(dto)); } + /** + * 供 Hardware 冲正人工审核退款,实际资金目标与金额只读取 Wallet 已持久化的原退款记录。 + * + * @param afterSaleId Hardware 售后单 ID + * @return 钱包资金已扣回时返回 {@code true} + */ + @Inner + @Operation(summary = "远程调用-冲正人工退款") + @PostMapping("/refund/rollback/{afterSaleId}") + public R rollbackRefund(@PathVariable Long afterSaleId) { + return R.ok(walletRechargeService.rollbackRefund(afterSaleId)); + } + /** * 查询售后单是否已经形成钱包退款记录,仅用于跨服务一致性恢复,不提供管理端修改能力。 * diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeService.java index db5495010..8c05c138d 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeService.java @@ -80,6 +80,14 @@ public interface WalletRechargeService extends IService { */ WalletRechargeVo refund(WalletRechargeRefundDto dto); + /** + * 冲正已完成的人工钱包退款并强制扣回原到账金额,退款状态用于保证重复调用不重复扣款。 + * + * @param afterSaleId Hardware 售后单 ID + * @return 已完成或已处于冲正状态时返回 {@code true} + */ + Boolean rollbackRefund(Long afterSaleId); + /** * 按 Hardware 售后单 ID 查询钱包退款记录。 * diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java index 5dfae29b6..b8ddb7fac 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java @@ -25,6 +25,7 @@ import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeRefundDto; import com.bsd.pjcloud.wallet.api.dto.recharge.app.GuardianWardRechargeCreateDto; import com.bsd.pjcloud.wallet.api.dto.recharge.app.WalletAppRechargeDto; import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; +import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity; import com.bsd.pjcloud.wallet.api.entity.WalletTypeEntity; import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity; import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargePackageEntity; @@ -73,6 +74,9 @@ import java.util.Set; @Service public class WalletRechargeServiceImpl extends ServiceImpl implements WalletRechargeService { + /** 人工退款冲正流水业务号前缀,售后单 ID 保证财务流水可以反查原退款。 */ + private static final String REFUND_ROLLBACK_BIZ_PREFIX = "refund_rollback:"; + @Lazy private final WalletRechargeService walletRechargeService; private final WalletRechargePackageService walletRechargePackageService; @@ -286,7 +290,7 @@ public class WalletRechargeServiceImpl extends ServiceImpl() + .eq(WalletTransactionEntity::getWalletId, walletId) + .eq(WalletTransactionEntity::getBizId, REFUND_ROLLBACK_BIZ_PREFIX + afterSaleId) + .eq(WalletTransactionEntity::getBizType, WalletBizEnum.REFUND.getCode()) + .orderByDesc(WalletTransactionEntity::getId) + .last("limit 1")); + } + /** * 校验幂等退款记录的资金目标和金额,防止错误复用售后单 ID 静默返回另一笔退款。 * diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImplTest.java index f455fafe7..58f8c8902 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImplTest.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImplTest.java @@ -4,10 +4,12 @@ import com.bsd.pjcloud.admin.api.feign.RemoteUserFaceService; import com.bsd.pjcloud.admin.api.feign.RemoteUserRelationService; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum; import com.bsd.pjcloud.hard.api.feign.RemoteHardOrderService; import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum; import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeRefundDto; import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; +import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity; import com.bsd.pjcloud.wallet.api.entity.mch.MchAutoRefundConfigEntity; import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity; import com.bsd.pjcloud.wallet.mapper.WalletManageMapper; @@ -24,10 +26,13 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InOrder; import org.mockito.Mock; +import org.mockito.ArgumentCaptor; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DuplicateKeyException; import org.springframework.test.util.ReflectionTestUtils; +import java.time.LocalDateTime; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; @@ -211,6 +216,83 @@ class WalletRechargeServiceImplTest { verify(walletTransactionService, never()).addWalletBalance(anyLong(), anyInt(), anyString(), any(WalletBizEnum.class)); } + /** + * 已完成退款首次冲正时必须强制扣回原金额,并把退款记录切换为未退款状态作为重试幂等依据。 + */ + @Test + void completedRefundRollbackShouldDeductBalanceAndMarkNotRefunded() { + WalletRechargeEntity existing = existingRefund(20L, 100); + when(rechargeMapper.selectOne(any())).thenReturn(existing); + when(walletTransactionService.reduceWalletBalance(20L, 100, "refund_rollback:50", + WalletBizEnum.REFUND, true)).thenReturn(new WalletTransactionEntity()); + when(rechargeMapper.updateById(any(WalletRechargeEntity.class))).thenReturn(1); + + assertEquals(Boolean.TRUE, service.rollbackRefund(50L)); + + verify(walletTransactionService).reduceWalletBalance(20L, 100, "refund_rollback:50", + WalletBizEnum.REFUND, true); + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(WalletRechargeEntity.class); + verify(rechargeMapper).updateById(updateCaptor.capture()); + assertEquals(HardRefundStatusEnum.NONE.getStatus(), updateCaptor.getValue().getRefundStatus()); + } + + /** + * 已冲正退款的重复请求应直接成功,不能再次形成负向钱包流水。 + */ + @Test + void duplicateRefundRollbackShouldNotDeductAgain() { + WalletRechargeEntity existing = existingRefund(20L, 100); + existing.setRefundStatus(HardRefundStatusEnum.NONE.getStatus()); + WalletTransactionEntity rollbackTransaction = new WalletTransactionEntity(); + rollbackTransaction.setPrice(-100); + when(rechargeMapper.selectOne(any())).thenReturn(existing); + when(walletTransactionService.getOne(any())).thenReturn(rollbackTransaction); + + assertEquals(Boolean.TRUE, service.rollbackRefund(50L)); + + verify(walletTransactionService, never()).reduceWalletBalance(anyLong(), anyInt(), anyString(), + any(WalletBizEnum.class), any(Boolean.class)); + verify(rechargeMapper, never()).updateById(any(WalletRechargeEntity.class)); + } + + /** + * 冲正后再次同意退款时应重新入账一次并恢复成功状态,后续重复同意仍由成功状态拦截。 + */ + @Test + void refundedAgainAfterRollbackShouldCreditBalanceAndRestoreSuccessState() { + WalletRechargeEntity existing = existingRefund(20L, 100); + existing.setRefundStatus(HardRefundStatusEnum.NONE.getStatus()); + WalletTransactionEntity rollbackTransaction = new WalletTransactionEntity(); + rollbackTransaction.setPrice(-100); + when(rechargeMapper.selectOne(any())).thenReturn(existing); + when(walletTransactionService.getOne(any())).thenReturn(rollbackTransaction); + when(rechargeMapper.updateById(any(WalletRechargeEntity.class))).thenReturn(1); + + service.refund(refundRequest(100, null)); + + verify(walletTransactionService).addWalletBalance(20L, 100, "50", WalletBizEnum.REFUND); + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(WalletRechargeEntity.class); + verify(rechargeMapper).updateById(updateCaptor.capture()); + assertEquals(HardRefundStatusEnum.SUCCESS.getStatus(), updateCaptor.getValue().getRefundStatus()); + } + + /** + * 历史退款记录即使状态仍为默认值,只要没有负向冲正流水就必须按已到账处理,不能重复增加余额。 + */ + @Test + void historicalDefaultRefundStatusWithoutRollbackTransactionShouldNotCreditAgain() { + WalletRechargeEntity existing = existingRefund(20L, 100); + existing.setRefundStatus(HardRefundStatusEnum.NONE.getStatus()); + when(rechargeMapper.selectOne(any())).thenReturn(existing); + when(walletTransactionService.getOne(any())).thenReturn(null); + + service.refund(refundRequest(100, null)); + + verify(walletTransactionService, never()).addWalletBalance(anyLong(), anyInt(), anyString(), + any(WalletBizEnum.class)); + verify(rechargeMapper, never()).updateById(any(WalletRechargeEntity.class)); + } + /** * 构造固定售后单的退款请求。 * @@ -249,6 +331,8 @@ class WalletRechargeServiceImplTest { entity.setWalletId(walletId); entity.setPayRefundOrderId(50L); entity.setRefundTotalPrice(price); + entity.setRefundStatus(HardRefundStatusEnum.SUCCESS.getStatus()); + entity.setRefundTime(LocalDateTime.of(2026, 8, 27, 10, 0)); return entity; } }