From 3197fa73d5f7f5df73177c3a0577530fc7bc1494 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 4 Aug 2026 17:11:20 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=E5=A4=9A=E9=A4=90=E6=AE=B5=E8=AE=A2?= =?UTF-8?q?=E9=A4=90=E6=A8=A1=E5=BC=8F=20feat:=E9=AA=A8=E6=9E=B6=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/app/HardCarBatchSaveOrUpdateDto.java | 24 + ...ardAppCreateDailyMenuReserveOrdersDto.java | 88 ++++ .../api/entity/order/HardParentCarEntity.java | 11 +- .../controller/order/HardCarController.java | 39 ++ .../controller/order/HardOrderController.java | 14 + .../pjcloud/hard/service/HardCarService.java | 19 +- .../hard/service/HardOrderService.java | 6 + .../impl/order/HardCarServiceImpl.java | 215 ++++++++- .../impl/order/HardOrderServiceImpl.java | 431 +++++++++++++++++- 9 files changed, 838 insertions(+), 9 deletions(-) create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/app/HardCarBatchSaveOrUpdateDto.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateDailyMenuReserveOrdersDto.java diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/app/HardCarBatchSaveOrUpdateDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/app/HardCarBatchSaveOrUpdateDto.java new file mode 100644 index 000000000..f5523527a --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/app/HardCarBatchSaveOrUpdateDto.java @@ -0,0 +1,24 @@ +package com.bsd.pjcloud.hard.api.dto.app; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +/** + * App 批量新增或更新购物车请求。 + * + *

每个分组会生成独立父购物车,用于保留多日期、多餐段订餐时各组菜品的边界。

+ */ +@Data +public class HardCarBatchSaveOrUpdateDto { + + /** + * 购物车分组列表;同一次请求中的分组必须属于同一用户、商户和硬件业务类型。 + */ + @Valid + @NotEmpty(message = "购物车分组不能为空") + private List<@NotNull(message = "购物车分组不能为空") HardCarSaveOrUpdateDto> groups; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateDailyMenuReserveOrdersDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateDailyMenuReserveOrdersDto.java new file mode 100644 index 000000000..50f7bebd8 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateDailyMenuReserveOrdersDto.java @@ -0,0 +1,88 @@ +package com.bsd.pjcloud.hard.api.dto.order.app; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDate; +import java.util.List; + +/** + * App 每日菜谱多日期、多餐段批量订餐请求。 + * + *

一个分组对应一个父购物车和一个预约日期餐段,服务端会为每个分组下的每位实际用餐人 + * 创建独立订单,并统一从当前付款人选择的钱包扣款。

+ */ +@Data +public class HardAppCreateDailyMenuReserveOrdersDto { + + /** + * 实际用餐人 ID 列表,只允许当前付款人本人及其有效被监护人,最多同时为 20 人订餐。 + */ + @NotEmpty(message = "用餐人不能为空") + @Size(max = 20, message = "一次最多选择20位用餐人") + private List dinerUserIds; + + /** + * 支付平台类型,当前接口只支持 App 钱包编码 30,避免把微信或支付宝选择误处理为钱包直扣。 + */ + @NotNull(message = "支付方式不能为空") + @Pattern(regexp = "30", message = "每日菜谱批量订餐暂仅支持钱包支付") + private String payType; + + /** + * 付款人本次选择的钱包类型 ID,服务端会按当前登录用户再次校验钱包归属和余额。 + */ + @NotNull(message = "钱包类型不能为空") + private Long walletTypeId; + + /** + * 按日期餐段拆分的购物车及预约上下文,最多提交 31 个分组。 + */ + @Valid + @NotEmpty(message = "订餐分组不能为空") + @Size(max = 31, message = "一次最多提交31个日期餐段") + private List groups; + + /** + * 单个日期餐段的购物车和预约参数。 + */ + @Data + public static class Group { + + /** + * 每日菜谱批量购物车接口返回的父购物车 ID,必须属于当前付款人且尚未创建订单。 + */ + @NotNull(message = "购物车不能为空") + private Long carParentId; + + /** + * 动态预约 ID;周期预约场景可为空,由预约规则 ID 定位配置。 + */ + private Long apptId; + + /** + * 周期预约规则 ID;动态预约场景可为空,由动态预约 ID 定位配置。 + */ + private Long apptRuleId; + + /** + * 餐段协议编码,服务端会与预约配置解析结果保持一致。 + */ + private String hourType; + + /** + * 预约时间类型,沿用预约列表返回的协议值并参与预约有效性校验。 + */ + private String apptTimeType; + + /** + * 实际用餐日期,精确到日,并用于同日同餐段重复预约校验。 + */ + @NotNull(message = "预约日期不能为空") + private LocalDate apptDate; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardParentCarEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardParentCarEntity.java index 32ac9f731..e4512ac2d 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardParentCarEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardParentCarEntity.java @@ -106,8 +106,17 @@ public class HardParentCarEntity extends Model { @Schema(description = "租户ID") private Long tenantId; + /** + * 父购物车扩展配置,统一序列化到现有 JSON 列;字段均为可选以兼容历史购物车数据。 + */ @Data public static class TakeOutSetting { + /** + * 购物车业务场景;每日菜谱写入 DAILY_MENU,历史外卖购物车为空。 + * 该值保存在现有 JSON 列中,不新增数据库字段。 + */ + private String cartScene; + /** * 起送价格(分) */ @@ -124,4 +133,4 @@ public class HardParentCarEntity extends Model { private Integer userTakeOutAmt; } -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarController.java index 809398be7..bb84f2d47 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarController.java @@ -13,6 +13,7 @@ import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; +import com.bsd.pjcloud.hard.api.dto.app.HardCarBatchSaveOrUpdateDto; import com.bsd.pjcloud.hard.api.dto.app.HardCarSaveOrUpdateDto; import com.bsd.pjcloud.hard.api.dto.order.HardCarDto; import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleCarEntity; @@ -32,6 +33,7 @@ import org.springframework.http.HttpHeaders; import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -95,6 +97,43 @@ public class HardCarController { hardCarService.saveOrUpload(dto); return R.ok(true); } + + /** + * 一次上传同一商户下按日期和餐段拆分的多组购物车,并按请求顺序返回各组父购物车 ID。 + */ + @ApiCryptoAes + @PostMapping("/app/batch-save-or-upload") + @Operation(summary = "批量新增或者更新购物车") + public R> batchSaveOrUpload(@Validated @RequestBody HardCarBatchSaveOrUpdateDto dto) { + PjcloudUser user = SecurityUtils.getUser(); + dto.getGroups().forEach(group -> { + group.setUserId(user.getId()); + group.setOrderType(HardOrderTypeEnum.RESERVE); + if (ObjectUtil.isEmpty(group.getHardType())) { + group.setHardType(HardTypeEnum.SUPERMARKET); + } + }); + return R.ok(hardCarService.batchSaveOrUpload(dto)); + } + + /** + * 批量上传每日菜谱购物车;接口层固定使用食堂预约类型,商品 ID 对应 {@code canteen_spu.id}。 + * + * @param dto 按日期餐段拆分的购物车分组,用户和业务类型由服务端覆盖 + * @return 与请求分组顺序一致的父购物车 ID 列表 + */ + @ApiCryptoAes + @PostMapping("/app/daily-menu/batch-save-or-upload") + @Operation(summary = "批量上传每日菜谱购物车") + public R> batchSaveDailyMenu(@Validated @RequestBody HardCarBatchSaveOrUpdateDto dto) { + PjcloudUser user = SecurityUtils.getUser(); + dto.getGroups().forEach(group -> { + group.setUserId(user.getId()); + group.setHardType(HardTypeEnum.CANTEEN); + group.setOrderType(HardOrderTypeEnum.RESERVE); + }); + return R.ok(hardCarService.batchSaveDailyMenu(dto)); + } /* ************************************* end app end ************************************* */ @GetMapping("/order-id") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java index f6ae810ab..4caec4d2c 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java @@ -38,6 +38,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; import com.bsd.pjcloud.hard.api.dto.order.HardOrderPreferencesDto; import com.bsd.pjcloud.hard.api.dto.order.HardShipmentDto; import com.bsd.pjcloud.hard.api.dto.order.act.HardCreateActOrderDto; +import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateDailyMenuReserveOrdersDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateFarmerOrderDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateOrderDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateReserveOrdersDto; @@ -343,6 +344,19 @@ public class HardOrderController { return R.ok(hardwareOrderService.appCreateReserveOrders(SecurityUtils.getUser().getId(), body)); } + /** + * 使用每日菜谱批量购物车一次创建多个日期餐段的预约订单,避免 App 逐组提交产生部分成功。 + */ + @Operation(summary = "app创建每日菜谱批量预定订单", description = "一次处理多个日期餐段,并为每位用餐人创建独立订单") + @ApiCryptoAes + @PostMapping("/app/create-daily-menu-reserve-orders") + @Idempotent(expireTime = 30, + key = "'/app/hard-order/app/create-daily-menu-reserve-orders:' + #body.getGroups().get(0).getCarParentId() + ':' + T(com.bsd.pjcloud.common.security.util.SecurityUtils).getUser().getId()", + info = "正在创建订单中,请勿重复操作", delKey = true) + public R appCreateDailyMenuReserveOrders(@Validated @RequestBody HardAppCreateDailyMenuReserveOrdersDto body) { + return R.ok(hardwareOrderService.appCreateDailyMenuReserveOrders(SecurityUtils.getUser().getId(), body)); + } + @Operation(summary = "app创建食堂预定订单", description = "app创建食堂预定订单") @ApiCryptoAes @GetMapping("/app/create-order") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java index 42e8e6dfc..fe8e1f9fa 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.IService; import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; +import com.bsd.pjcloud.hard.api.dto.app.HardCarBatchSaveOrUpdateDto; import com.bsd.pjcloud.hard.api.dto.app.HardCarSaveOrUpdateDto; import com.bsd.pjcloud.hard.api.dto.order.HardCarDto; import com.bsd.pjcloud.hard.api.dto.order.HardCreateCarDto; @@ -35,6 +36,22 @@ public interface HardCarService extends IService { */ void saveOrUpload(HardCarSaveOrUpdateDto dto); + /** + * 批量替换同一用户、商户和业务类型下尚未绑定订单的购物车,并为每个分组创建独立父购物车。 + * + * @param dto 按业务场景拆分的购物车分组,调用前需由接口层写入可信用户与订单类型 + * @return 与请求分组顺序一致的父购物车 ID 列表 + */ + List batchSaveOrUpload(HardCarBatchSaveOrUpdateDto dto); + + /** + * 批量保存每日菜谱购物车,所有商品 ID 均按 {@code canteen_spu.id} 解析。 + * + * @param dto 按日期餐段拆分的食堂预约购物车分组 + * @return 与请求分组顺序一致的父购物车 ID 列表 + */ + List batchSaveDailyMenu(HardCarBatchSaveOrUpdateDto dto); + /** * 创建购物车 */ @@ -49,4 +66,4 @@ public interface HardCarService extends IService { * 生成购买商品的描述 */ String computerCarSubject(HardTypeEnum sysType, HardOrderTypeEnum orderType, List list); -} \ No newline at end of file +} 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 c12575784..16e5a0e15 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 @@ -18,6 +18,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardCreateCarDto; import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; import com.bsd.pjcloud.hard.api.dto.order.HardShipmentDto; import com.bsd.pjcloud.hard.api.dto.order.act.HardCreateActOrderDto; +import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateDailyMenuReserveOrdersDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateFarmerOrderDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateOrderDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateReserveOrdersDto; @@ -134,6 +135,11 @@ public interface HardOrderService extends IService { */ HardAppCreateReserveOrdersVo appCreateReserveOrders(Long payerUserId, @Valid HardAppCreateReserveOrdersDto dto); + /** + * 当前付款人使用每日菜谱购物车,为多位用餐人批量创建多个日期餐段的独立预约订单。 + */ + HardAppCreateReserveOrdersVo appCreateDailyMenuReserveOrders(Long payerUserId, @Valid HardAppCreateDailyMenuReserveOrdersDto dto); + /** * 校验并规范化当前付款人提交的用餐人。 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java index b98f65eaf..b8ab385f8 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java @@ -11,10 +11,12 @@ import com.bsd.pjcloud.canteen.api.feign.RemoteOnlineCanteenSpuService; import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity; import com.bsd.pjcloud.canteen.vo.app.CanteenOnlineSpuAppVO; import com.bsd.pjcloud.common.core.util.BeanUtils; +import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum; import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; +import com.bsd.pjcloud.hard.api.dto.app.HardCarBatchSaveOrUpdateDto; import com.bsd.pjcloud.hard.api.dto.app.HardCarSaveOrUpdateDto; import com.bsd.pjcloud.hard.api.dto.order.HardCarDto; import com.bsd.pjcloud.hard.api.dto.order.HardCreateCarDto; @@ -47,7 +49,12 @@ import org.springframework.validation.annotation.Validated; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; /** @@ -62,6 +69,9 @@ import java.util.concurrent.atomic.AtomicInteger; @Service public class HardCarServiceImpl extends ServiceImpl implements HardCarService { + /** 每日菜谱父购物车写入现有扩展 JSON 的场景值,用于下单、退款时区分 {@code canteen_spu} 商品来源。 */ + private static final String DAILY_MENU_CART_SCENE = "DAILY_MENU"; + private final HardOrderMapper hardCarMapper; private final HardParentCarMapper hardParentCarMapper; private final HardSpuService hardSpuService; @@ -130,6 +140,10 @@ public class HardCarServiceImpl extends ServiceImpl carList = dto.getList().stream().filter(v -> v.getProductCount() > 0).toList(); HardParentCarEntity parentCar = getParentCar(dto.getMerchantId(), dto.getUserId(), dto.getHardType()); if (ObjectUtil.isNotEmpty(parentCar)) { @@ -153,6 +167,182 @@ public class HardCarServiceImpl extends ServiceImpl batchSaveOrUpload(HardCarBatchSaveOrUpdateDto dto) { + // 通用批量接口不得由客户端伪造每日菜谱商品来源,避免同 ID 商品跨表错配。 + dto.getGroups().stream() + .filter(Objects::nonNull) + .map(HardCarSaveOrUpdateDto::getTakeOutSetting) + .filter(Objects::nonNull) + .forEach(setting -> setting.setCartScene(null)); + return saveBatchCarts(dto, false); + } + + /** + * 批量上传每日菜谱购物车,固定将商品 ID 解释为 {@code canteen_spu.id}。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + @Lock4j(keys = {"#dto.groups[0].userId"}) + public List batchSaveDailyMenu(HardCarBatchSaveOrUpdateDto dto) { + // 场景标记必须由服务端覆盖,不能信任 App 自报的商品来源;现有 JSON 字段可随克隆购物车完整保留。 + dto.getGroups().forEach(group -> { + HardParentCarEntity.TakeOutSetting setting = new HardParentCarEntity.TakeOutSetting(); + setting.setCartScene(DAILY_MENU_CART_SCENE); + group.setTakeOutSetting(setting); + }); + return saveBatchCarts(dto, true); + } + + /** + * 清理旧草稿后创建多个父购物车,并根据调用场景选择通用商品或每日菜谱商品保存逻辑。 + */ + private List saveBatchCarts(HardCarBatchSaveOrUpdateDto dto, boolean useDailyMenuProducts) { + if (dto == null || ObjectUtil.isEmpty(dto.getGroups())) { + throw new RuntimeException("购物车分组不能为空"); + } + HardCarSaveOrUpdateDto firstGroup = dto.getGroups().get(0); + if (firstGroup == null || !ObjectUtil.isAllNotEmpty(firstGroup.getUserId(), firstGroup.getMerchantId(), firstGroup.getHardType(), firstGroup.getOrderType())) { + throw new RuntimeException("购物车业务信息不完整"); + } + boolean containsDifferentOwner = dto.getGroups().stream().anyMatch(group -> group == null + || !Objects.equals(group.getUserId(), firstGroup.getUserId()) + || !Objects.equals(group.getMerchantId(), firstGroup.getMerchantId()) + || !Objects.equals(group.getHardType(), firstGroup.getHardType()) + || !Objects.equals(group.getOrderType(), firstGroup.getOrderType())); + if (containsDifferentOwner) { + throw new RuntimeException("批量购物车必须属于同一用户、商户和业务类型"); + } + clearUnorderedCreateCarts(firstGroup.getUserId(), firstGroup.getMerchantId(), firstGroup.getHardType(), + useDailyMenuProducts ? DAILY_MENU_CART_SCENE : null); + List parentCarIds = new ArrayList<>(dto.getGroups().size()); + for (int groupIndex = 0; groupIndex < dto.getGroups().size(); groupIndex++) { + HardCarSaveOrUpdateDto group = dto.getGroups().get(groupIndex); + if (ObjectUtil.isEmpty(group.getList())) { + throw new RuntimeException("第" + (groupIndex + 1) + "组购物车不能为空"); + } + List carList = group.getList().stream() + .filter(Objects::nonNull) + .filter(item -> item.getProductId() != null) + .filter(item -> item.getProductCount() != null && item.getProductCount() > 0) + .toList(); + if (carList.size() != group.getList().size()) { + throw new RuntimeException("第" + (groupIndex + 1) + "组购物车商品信息不完整或数量无效"); + } + HardParentCarEntity parentCar = HardParentCarEntity.builder() + .userId(group.getUserId()) + .merchantId(group.getMerchantId()) + .hardType(group.getHardType().getType()) + .orderStatus(HardOrderStatusEnum.CREATE.getType()) + .takeOutSettingJson(group.getTakeOutSetting()) + .build(); + if (hardParentCarMapper.insert(parentCar) != 1) { + throw new RuntimeException("第" + (groupIndex + 1) + "组父购物车创建失败"); + } + if (useDailyMenuProducts) { + createDailyMenuCartItems(parentCar.getId(), group.getMerchantId(), carList); + } else { + getSelf().appCreateCard(parentCar.getId(), group.getHardType(), group.getOrderType(), carList); + } + if (getByParentCarId(parentCar.getId()).size() != carList.size()) { + throw new RuntimeException("第" + (groupIndex + 1) + "组存在无效商品"); + } + parentCarIds.add(parentCar.getId()); + } + return parentCarIds; + } + + /** + * 根据每日菜谱提交的 {@code canteen_spu.id} 生成购物车快照,非法商品整组拒绝而不是静默跳过。 + */ + private void createDailyMenuCartItems(Long parentCarId, Long merchantId, List productList) { + List productIds = productList.stream().map(HardCreateCarDto::getProductId).distinct().toList(); + R> response = canteenSpuService.getSpuListByIds(productIds); + if (response == null || !response.isOk() || response.getData() == null) { + String message = response == null || response.getMsg() == null ? "查询每日菜谱商品失败" : response.getMsg(); + throw new RuntimeException(message); + } + Map productMap = new HashMap<>(response.getData().size()); + response.getData().stream() + .filter(Objects::nonNull) + .filter(spu -> spu.getId() != null) + .forEach(spu -> productMap.putIfAbsent(spu.getId(), spu)); + + List cartItems = new ArrayList<>(productList.size()); + for (HardCreateCarDto product : productList) { + CanteenSpuEntity spu = productMap.get(product.getProductId()); + if (spu == null || !Objects.equals(spu.getMerchantId(), merchantId)) { + throw new RuntimeException("每日菜谱商品不存在或不属于当前商户"); + } + if (spu.getPrice() == null || spu.getPrice() < 0) { + throw new RuntimeException(spu.getName() + "价格异常,请联系商户"); + } + long totalPrice = Math.multiplyExact((long) spu.getPrice(), product.getProductCount().longValue()); + String productNature = ObjectUtil.isNotEmpty(product.getProductNature()) + ? product.getProductNature().getType() + : HardProductNatureEnum.DEFAULT.getType(); + cartItems.add(HardCarEntity.builder() + .parentCarId(parentCarId) + .productId(spu.getId()) + .productName(spu.getName()) + .productPic(spu.getPicUrl()) + .productPrice(spu.getPrice()) + .productNature(productNature) + .productCount(product.getProductCount()) + .productWeight(BigDecimal.ZERO) + .totalPrice(totalPrice) + .build()); + } + insertCartItemsInCurrentTransaction(cartItems); + } + + /** + * 按商品来源场景清理未绑定订单的旧草稿购物车;已有订单引用的父购物车必须保留, + * 每日菜谱与普通食堂购物车也必须相互隔离,避免一个页面结算时删除另一个页面的草稿。 + * + * @param userId 草稿所属用户 ID + * @param merchantId 草稿所属商户 ID + * @param hardType 草稿业务类型 + * @param cartScene 服务端购物车场景;为空时仅清理没有场景标记的普通购物车 + */ + private void clearUnorderedCreateCarts(Long userId, Long merchantId, HardTypeEnum hardType, String cartScene) { + List candidates = hardParentCarMapper.selectList(new LambdaQueryWrapper() + .eq(HardParentCarEntity::getUserId, userId) + .eq(HardParentCarEntity::getMerchantId, merchantId) + .eq(HardParentCarEntity::getHardType, hardType.getType()) + .eq(HardParentCarEntity::getOrderStatus, HardOrderStatusEnum.CREATE.getType())) + .stream() + .filter(parentCar -> { + String existingScene = parentCar.getTakeOutSettingJson() == null + ? null + : parentCar.getTakeOutSettingJson().getCartScene(); + return cartScene == null ? ObjectUtil.isEmpty(existingScene) : Objects.equals(cartScene, existingScene); + }) + .toList(); + if (ObjectUtil.isEmpty(candidates)) { + return; + } + List candidateIds = candidates.stream().map(HardParentCarEntity::getId).toList(); + Set orderedParentCarIds = new HashSet<>(hardCarMapper.selectList(new LambdaQueryWrapper() + .select(HardOrderEntity::getParentCarId) + .in(HardOrderEntity::getParentCarId, candidateIds)) + .stream() + .map(HardOrderEntity::getParentCarId) + .filter(Objects::nonNull) + .toList()); + List removableParentCarIds = candidateIds.stream().filter(id -> !orderedParentCarIds.contains(id)).toList(); + if (ObjectUtil.isEmpty(removableParentCarIds)) { + return; + } + baseMapper.delete(new LambdaQueryWrapper().in(HardCarEntity::getParentCarId, removableParentCarIds)); + hardParentCarMapper.deleteBatchIds(removableParentCarIds); + } + @Override public Integer computerCarTotalPrice(HardTypeEnum sysType, HardOrderTypeEnum orderType, List list) { List productIds = list.stream().map(HardCreateCarDto::getProductId).toList(); @@ -308,7 +498,7 @@ public class HardCarServiceImpl extends ServiceImpl cartItems) { + for (HardCarEntity cartItem : cartItems) { + if (baseMapper.insert(cartItem) != 1) { + throw new RuntimeException("购物车商品保存失败"); + } + } + } + @Override public String computerCarSubject(HardTypeEnum sysType, HardOrderTypeEnum orderType, List list) { StringBuffer str = new StringBuffer(); @@ -608,4 +811,4 @@ public class HardCarServiceImpl extends ServiceImpl dinerUserIds = validateReserveDinerUserIds(payerUserId, dto.getDinerUserIds()); + List groups = validateDailyMenuReserveGroups(dto.getGroups()); + List cartLocks = acquireDailyMenuCartLocks(groups); + try { + registerDailyMenuCartLockRelease(cartLocks); + } catch (RuntimeException exception) { + releaseDailyMenuCartLocks(cartLocks); + throw exception; + } + + List contexts = loadDailyMenuReserveGroupContexts(payerUserId, groups); + Long merchantId = contexts.get(0).parentCar().getMerchantId(); + MchManageVo merchant = validateDailyMenuReserveMerchant(merchantId); + validateMerchantReserveDinerPermissions(payerUserId, dinerUserIds, merchant); + validateDailyMenuCartProducts(merchantId, contexts); + validateDailyMenuSingleProductLimit(merchant, contexts); + + WalletManageEntity wallet = getDailyMenuPaymentWallet(payerUserId, dto.getWalletTypeId()); + List dinerUsers = Optional.ofNullable(userService.getInnerUserById(dinerUserIds)) + .map(R::getData) + .orElseThrow(() -> new RuntimeException("查询用餐人失败")); + Map dinerUserMap = dinerUsers.stream() + .filter(Objects::nonNull) + .filter(user -> user.getUserId() != null) + .collect(Collectors.toMap(SysUser::getUserId, Function.identity(), (first, second) -> first)); + if (dinerUserMap.size() != dinerUserIds.size()) { + throw new RuntimeException("用餐人不存在"); + } + for (Long dinerUserId : dinerUserIds) { + Long reserveCount = isHaveCanteenReserve(dinerUserId); + if (Math.addExact(reserveCount, (long) contexts.size()) > 20L) { + throw new RuntimeException("用餐人已有" + reserveCount + "笔未核销的预约订单,本次订餐后将超过20笔上限"); + } + } + + int singleDinerTotalPrice = contexts.stream() + .mapToInt(DailyMenuReserveGroupContext::singlePrice) + .reduce(0, Math::addExact); + int totalPayPrice = Math.multiplyExact(singleDinerTotalPrice, dinerUserIds.size()); + if (totalPayPrice <= 0) { + throw new RuntimeException("购物车金额异常"); + } + if (wallet.getBalance() == null || wallet.getBalance() < totalPayPrice) { + throw new RuntimeException("钱包余额不足"); + } + + // 固定按日期和餐段顺序获取冲突锁,避免两个批量请求以相反分组顺序互相等待。 + contexts.stream() + .sorted(Comparator.comparing((DailyMenuReserveGroupContext context) -> context.reservation().apptDate()) + .thenComparing(context -> context.reservation().hourType(), Comparator.nullsFirst(String::compareTo))) + .forEach(context -> validateSingleReservationOnlyPerMeal(merchant, dinerUserIds, context.reservation())); + + List createdOrders = new ArrayList<>(); + try { + for (DailyMenuReserveGroupContext context : contexts) { + String subject = buildDailyMenuReserveOrderSubject(context.carList()); + for (int dinerIndex = 0; dinerIndex < dinerUserIds.size(); dinerIndex++) { + Long dinerUserId = dinerUserIds.get(dinerIndex); + Long parentCarId = dinerIndex == 0 + ? context.parentCar().getId() + : cloneReserveCart(context.parentCar(), context.carList(), payerUserId); + HardOrderEntity order = buildReserveOrder(dinerUserMap.get(dinerUserId), parentCarId, merchant, + wallet, context.reservation(), context.singlePrice(), subject); + if (baseMapper.insert(order) != 1) { + throw new RuntimeException("创建订餐订单失败"); + } + createdOrders.add(order); + } + } + for (HardOrderEntity order : createdOrders) { + reduceReserveOrderBalance(order); + markReserveOrderPaid(order); + } + // 方法体成功后仍可能在数据库提交阶段失败,回滚回调负责补偿已经完成的跨服务钱包扣款。 + registerDailyMenuWalletRollbackCompensation(createdOrders); + } catch (Exception exception) { + compensateReserveOrderWallets(createdOrders); + throw exception; + } + + registerReserveOrderAfterCommitTasks(createdOrders, dinerUserMap); + HardAppCreateReserveOrdersVo result = new HardAppCreateReserveOrdersVo(); + result.setOrderIds(createdOrders.stream().map(HardOrderEntity::getId).toList()); + result.setOrderCount(createdOrders.size()); + result.setTotalPayPrice(totalPayPrice); + return result; + } + + /** + * 将每日菜谱钱包补偿绑定到本地事务回滚,覆盖方法返回后发生的数据库提交失败。 + * 钱包补偿接口按订单业务号幂等,方法体异常路径即使已经补偿,再次调用也不会重复入账。 + * + * @param createdOrders 已完成远程钱包扣款、等待本地事务提交的订单 + */ + private void registerDailyMenuWalletRollbackCompensation(List createdOrders) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + throw new RuntimeException("订餐事务状态异常,请稍后重试"); + } + List rollbackOrders = List.copyOf(createdOrders); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** + * 本地事务明确回滚时补偿远程钱包;状态未知时保留流水,避免数据库实际已提交却误退款。 + * + * @param status Spring 事务完成状态 + */ + @Override + public void afterCompletion(int status) { + if (status == TransactionSynchronization.STATUS_ROLLED_BACK) { + compensateReserveOrderWallets(rollbackOrders); + } + } + }); + } + + /** + * 规范化每日菜谱分组并拒绝重复父购物车,避免同一购物车在一个批次内被创建多次订单。 + * + * @param groups 接口提交的日期餐段分组 + * @return 保持请求顺序的独立列表 + */ + private List validateDailyMenuReserveGroups( + List groups) { + if (ObjectUtil.isEmpty(groups) || groups.stream().anyMatch(Objects::isNull)) { + throw new RuntimeException("订餐分组不能为空"); + } + Set parentCarIds = groups.stream() + .map(HardAppCreateDailyMenuReserveOrdersDto.Group::getCarParentId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + if (parentCarIds.size() != groups.size()) { + throw new RuntimeException("请勿重复提交同一购物车"); + } + return new ArrayList<>(groups); + } + + /** + * 按父购物车 ID 排序获取批量下单锁,锁在当前事务完成后统一释放。 + * + * @param groups 已完成基础校验的日期餐段分组 + * @return 当前请求持有的购物车锁 + */ + private List acquireDailyMenuCartLocks(List groups) { + List locks = new ArrayList<>(); + try { + for (Long parentCarId : groups.stream() + .map(HardAppCreateDailyMenuReserveOrdersDto.Group::getCarParentId) + .sorted() + .toList()) { + String lockKey = "hard:daily-menu-reserve:cart:" + parentCarId; + LockInfo lock = lockTemplate.lock(lockKey, RESERVATION_SLOT_LOCK_EXPIRE_MILLIS, + RESERVATION_SLOT_LOCK_WAIT_MILLIS); + if (lock == null) { + throw new RuntimeException("订餐订单正在处理中,请勿重复提交"); + } + locks.add(lock); + } + return locks; + } catch (RuntimeException exception) { + releaseDailyMenuCartLocks(locks); + throw exception; + } + } + + /** + * 将每日菜谱购物车锁绑定到当前事务生命周期,确保订单回滚前其他请求不能再次消费同一购物车。 + * + * @param locks 当前请求已经获取的购物车锁 + */ + private void registerDailyMenuCartLockRelease(List locks) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + throw new RuntimeException("订餐事务状态异常,请稍后重试"); + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** + * 无论事务提交还是回滚,都在本地事务完成后逆序释放全部父购物车锁。 + * + * @param status Spring 事务完成状态 + */ + @Override + public void afterCompletion(int status) { + releaseDailyMenuCartLocks(locks); + } + }); + } + + /** + * 逆序释放已获取的每日菜谱购物车锁,单个锁释放失败不阻断其他锁的清理。 + * + * @param locks 需要释放的购物车锁 + */ + private void releaseDailyMenuCartLocks(List locks) { + ListIterator iterator = locks.listIterator(locks.size()); + while (iterator.hasPrevious()) { + LockInfo lock = iterator.previous(); + try { + if (!lockTemplate.releaseLock(lock)) { + log.warn("|{}|每日菜谱购物车锁释放失败,lockKey:|{}|", TAG, lock.getLockKey()); + } + } catch (Exception exception) { + log.error("|{}|每日菜谱购物车锁释放异常,lockKey:|{}|", TAG, lock.getLockKey(), exception); + } + } + } + + /** + * 加载并校验每个父购物车及其预约上下文,所有分组必须属于同一付款人和同一食堂商户。 + * + * @param payerUserId 当前登录付款人 ID + * @param groups 每日菜谱日期餐段分组 + * @return 后续创建订单所需的服务端可信上下文 + */ + private List loadDailyMenuReserveGroupContexts(Long payerUserId, + List groups) { + List contexts = new ArrayList<>(groups.size()); + Long merchantId = null; + Set mealSlots = new HashSet<>(); + for (HardAppCreateDailyMenuReserveOrdersDto.Group group : groups) { + HardParentCarEntity parentCar = hardCarService.getParentCarById(group.getCarParentId()); + if (parentCar == null || !Objects.equals(parentCar.getUserId(), payerUserId)) { + throw new RuntimeException("购物车不存在或不属于当前用户"); + } + if (!Objects.equals(parentCar.getHardType(), HardTypeEnum.CANTEEN.getType())) { + throw new RuntimeException("仅食堂购物车可以提交每日菜谱订单"); + } + if (!isDailyMenuParentCar(parentCar)) { + throw new RuntimeException("购物车不是每日菜谱场景,请重新点击去支付"); + } + if (!Objects.equals(parentCar.getOrderStatus(), HardOrderStatusEnum.CREATE.getType())) { + throw new RuntimeException("购物车状态已变更,请重新选择商品"); + } + if (baseMapper.selectCount(new LambdaQueryWrapper() + .eq(HardOrderEntity::getParentCarId, parentCar.getId())) > 0) { + throw new RuntimeException("购物车已创建订单,请勿重复提交"); + } + if (merchantId == null) { + merchantId = parentCar.getMerchantId(); + } else if (!Objects.equals(merchantId, parentCar.getMerchantId())) { + throw new RuntimeException("批量订餐必须属于同一商户"); + } + List carList = hardCarService.getByParentCarId(parentCar.getId()); + if (ObjectUtil.isEmpty(carList)) { + throw new RuntimeException("购物车不存在"); + } + validateReserveCartItems(carList); + HardApptReservationService.ReservationValidation reservation = hardApptReservationService + .validateReservation(parentCar.getMerchantId(), group.getApptId(), group.getApptRuleId(), + group.getHourType(), group.getApptDate(), group.getApptTimeType()); + String mealSlot = reservation.apptDate() + "::" + reservation.hourType(); + if (!mealSlots.add(mealSlot)) { + throw new RuntimeException("请勿重复提交同一天同一餐段"); + } + int singlePrice = Math.toIntExact(carList.stream().mapToLong(HardCarEntity::getTotalPrice).sum()); + if (singlePrice <= 0) { + throw new RuntimeException("购物车金额异常"); + } + contexts.add(new DailyMenuReserveGroupContext(parentCar, carList, reservation, singlePrice)); + } + return contexts; + } + + /** + * 查询并校验每日菜谱商户配置,防止其他食堂购物车绕过专用商品来源约束。 + * + * @param merchantId 批量购物车所属商户 ID + * @return 已确认开启每日菜谱订餐模式的食堂商户 + */ + private MchManageVo validateDailyMenuReserveMerchant(Long merchantId) { + R result = remoteMchService.getById(merchantId); + MchManageVo merchant = result == null || !result.isOk() ? null : result.getData(); + if (merchant == null) { + throw new RuntimeException(result == null ? "查询商户无响应" : StrUtil.blankToDefault(result.getMsg(), "商户不存在")); + } + if (!Objects.equals(merchant.getTypes(), MerchantTypeEnum.CANTEEN.getType())) { + throw new RuntimeException("购物车商户不是食堂商户"); + } + MchManageEntity.TableReservationConf conf = merchant.getTableReservationConf(); + if (conf == null || !Objects.equals(conf.getMode(), WalletMchTableReservationMode.DAILY_MENU.getCode())) { + throw new RuntimeException("当前商户未开启每日菜谱订餐模式"); + } + return merchant; + } + + /** + * 按当前付款人和支付抽屉选择的钱包类型查询钱包,拒绝空响应、越权钱包和异常余额。 + * + * @param payerUserId 当前登录付款人 ID + * @param walletTypeId 支付抽屉选择的钱包类型 ID + * @return 归属于当前付款人的可用钱包 + */ + private WalletManageEntity getDailyMenuPaymentWallet(Long payerUserId, Long walletTypeId) { + R result = remoteWalletService.getWalletByWalletTypeId(payerUserId, walletTypeId); + WalletManageEntity wallet = result == null || !result.isOk() ? null : result.getData(); + if (wallet == null) { + throw new RuntimeException(result == null ? "查询用户钱包无响应" : StrUtil.blankToDefault(result.getMsg(), "用户钱包不存在")); + } + if (!Objects.equals(wallet.getUserId(), payerUserId)) { + throw new RuntimeException("钱包不属于当前付款人"); + } + return wallet; + } + + /** + * 使用 {@code canteen_spu} 当前数据复核购物车商品归属、可见状态和价格快照,价格变化时要求重新结算。 + * + * @param merchantId 每日菜谱所属商户 ID + * @param contexts 已加载的日期餐段购物车上下文 + */ + private void validateDailyMenuCartProducts(Long merchantId, List contexts) { + List productIds = contexts.stream() + .flatMap(context -> context.carList().stream()) + .map(HardCarEntity::getProductId) + .distinct() + .toList(); + R> result = remoteCanteenSpuService.getSpuListByIds(productIds); + if (result == null || !result.isOk() || result.getData() == null) { + throw new RuntimeException(result == null ? "查询每日菜谱商品无响应" + : StrUtil.blankToDefault(result.getMsg(), "查询每日菜谱商品失败")); + } + Map productMap = result.getData().stream() + .filter(Objects::nonNull) + .filter(product -> product.getId() != null) + .collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first)); + for (DailyMenuReserveGroupContext context : contexts) { + for (HardCarEntity car : context.carList()) { + CanteenSpuEntity product = productMap.get(car.getProductId()); + if (product == null || !Objects.equals(product.getMerchantId(), merchantId) + || !Objects.equals(product.getIsShow(), "0")) { + throw new RuntimeException("每日菜谱商品不存在、已下架或不属于当前商户"); + } + if (product.getPrice() == null || product.getPrice() < 0) { + throw new RuntimeException(product.getName() + "价格异常,请联系商户"); + } + long currentTotalPrice = Math.multiplyExact((long) product.getPrice(), car.getProductCount().longValue()); + if (!Objects.equals(product.getPrice(), car.getProductPrice()) + || !Objects.equals(currentTotalPrice, car.getTotalPrice())) { + throw new RuntimeException(product.getName() + "价格已变化,请重新选择菜品"); + } + } + } + } + + /** + * 商户开启单商品限制时,服务端按商品份数再次拦截每个日期餐段超过一份的购物车。 + * + * @param merchant 当前每日菜谱商户 + * @param contexts 已加载的日期餐段购物车上下文 + */ + private void validateDailyMenuSingleProductLimit(MchManageVo merchant, List contexts) { + MchManageEntity.TableReservationConf conf = merchant.getTableReservationConf(); + if (conf == null || !Boolean.TRUE.equals(conf.getSingleProductOnlyInCart())) { + return; + } + boolean exceedsLimit = contexts.stream().anyMatch(context -> context.carList().stream() + .mapToLong(HardCarEntity::getProductCount) + .sum() > 1L); + if (exceedsLimit) { + throw new RuntimeException("该商户每个日期餐段仅允许选择一份商品"); + } + } + + /** + * 使用购物车内服务端生成的商品快照拼接每日菜谱订单摘要,避免再次走在线商品查询分支。 + * + * @param carList 一个日期餐段的购物车明细 + * @return 订单列表和详情使用的商品摘要 + */ + private String buildDailyMenuReserveOrderSubject(List carList) { + return carList.stream() + .map(car -> car.getProductName() + "x" + car.getProductCount()) + .collect(Collectors.joining(";", "", ";")); + } + + /** + * 从父购物车现有扩展 JSON 读取服务端写入的每日菜谱场景标记,格式异常时按非每日菜谱处理。 + * + * @param parentCar 待判断的父购物车 + * @return {@code true} 表示商品来自 {@code canteen_spu} 每日菜谱且不参与在线库存变更 + */ + private boolean isDailyMenuParentCar(HardParentCarEntity parentCar) { + return parentCar != null + && parentCar.getTakeOutSettingJson() != null + && Objects.equals(parentCar.getTakeOutSettingJson().getCartScene(), DAILY_MENU_CART_SCENE); + } + + /** + * 每个每日菜谱日期餐段在服务端完成校验后的下单上下文。 + * + * @param parentCar 当前付款人尚未下单的父购物车 + * @param carList 由服务端生成价格快照的购物车明细 + * @param reservation 预约服务规范化后的日期、餐段和预约规则 + * @param singlePrice 单位用餐人当前餐段应付金额,单位为分 + */ + private record DailyMenuReserveGroupContext(HardParentCarEntity parentCar, + List carList, + HardApptReservationService.ReservationValidation reservation, + int singlePrice) { + } + /** * 核心订单提交后再触发可幂等重试的下游任务;无事务调用时直接执行,便于复用和测试。 */ @@ -1365,6 +1779,16 @@ public class HardOrderServiceImpl extends ServiceImpl createdOrders) { // 对所有已创建订单发起幂等补偿;钱包服务只会退回实际存在的 PAYMENT 流水。 ListIterator walletIterator = createdOrders.listIterator(createdOrders.size()); while (walletIterator.hasPrevious()) { @@ -1384,7 +1808,6 @@ public class HardOrderServiceImpl extends ServiceImpl carList = hardCarService.getByParentCarId(order.getParentCarId()); if (ObjectUtil.isEmpty(carList)) throw new RuntimeException(CarErrorMsg + "错误,购物车不存在"); From 112c8dde3c1cc6826382e46d4382d03122aad19b Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 6 Aug 2026 09:51:33 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=E8=81=9A=E5=90=88=E7=A0=81=E6=94=AF?= =?UTF-8?q?=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/20260805_add_third_aggregate_pay.sql | 37 + nacos_local.env | 6 + .../api/constant/order/HardPayTypeEnum.java | 60 +- .../HardAggregateOrderCodePayStatusVo.java | 52 + .../vo/order/HardAggregateOrderCodePayVo.java | 42 + .../controller/hard/HardApiController.java | 41 + .../hard/job/HardAggregateOrderPayJob.java | 69 + .../hard/service/HardOrderService.java | 19 + .../impl/order/HardOrderServiceImpl.java | 267 ++++ .../feign/RemoterThirdAppPlusPayService.java | 53 +- .../ThirdAggregatePaySessionStatusEnum.java | 101 ++ .../constant/pay/ThirdPayOrderStatusEnum.java | 80 +- .../ThirdAggregateOrderCodePayDto.java | 53 + .../dto/appplus/ThirdOrderCodePayDto.java | 62 + .../pay/ThirdAggregatePaySessionEntity.java | 162 +++ .../ThirdAggregateOrderCodePayStatusVo.java | 53 + .../pay/ThirdAggregateOrderCodePayVo.java | 38 + .../pay/ThirdOrderCodePayStatusVo.java | 40 + .../vo/appplus/pay/ThirdOrderCodePayVo.java | 34 + .../AggregateWechatPayConfiguration.java | 40 + .../initconfig/WechatV3Configuration.java | 18 + .../ThirdAggregateOrderPayController.java | 172 +++ .../appplus/ThirdAppPlusController.java | 96 ++ .../appplus/ThirdMinAppPayCotroller.java | 21 +- .../job/ThirdAggregateOrderPayJob.java | 71 + .../thirdparty/job/ThirdAppPlusPayJob.java | 56 +- .../pay/ThirdAggregatePaySessionMapper.java | 14 + .../ThirdAggregateAlipayOauthIdentity.java | 59 + .../pay/ThirdAggregateCashierLaunch.java | 68 + .../pay/ThirdAggregateCashierPageData.java | 46 + .../pay/ThirdAggregatePayCreateContext.java | 50 + .../service/ThirdAggregateAliPayService.java | 51 + .../ThirdAggregateCashierHtmlRenderer.java | 176 +++ .../ThirdAggregateOrderPayService.java | 92 ++ .../ThirdAggregatePaySessionService.java | 92 ++ .../ThirdAggregateWechatPayService.java | 48 + .../service/ThirdAppPlusAliPayService.java | 88 +- .../service/ThirdAppPlusService.java | 33 + .../ThirdAppPlusWechatNativePayService.java | 44 + .../service/ThirdAppPlusWechatService.java | 12 +- .../ThirdAggregateAliPayServiceImpl.java | 327 +++++ .../ThirdAggregateOrderPayServiceImpl.java | 1214 +++++++++++++++++ .../ThirdAggregateWechatPayServiceImpl.java | 369 +++++ .../ThirdAppPlusAliPayServiceImpl.java | 456 ++++++- .../impl/appplus/ThirdAppPlusServiceImpl.java | 751 +++++++++- ...hirdAppPlusWechatNativePayServiceImpl.java | 269 ++++ .../ThirdAppPlusWechatServiceImpl.java | 256 +++- .../ThirdAggregatePaySessionServiceImpl.java | 207 +++ .../job/ThirdAppPlusPayJobTest.java | 59 + ...ThirdAggregateCashierHtmlRendererTest.java | 63 + .../ThirdAggregateAliPayServiceImplTest.java | 217 +++ ...ThirdAggregateOrderPayServiceImplTest.java | 285 ++++ ...hirdAggregateWechatPayServiceImplTest.java | 162 +++ .../ThirdAppPlusAliPayServiceImplTest.java | 314 +++++ ...irdAppPlusServiceImplOrderCodePayTest.java | 522 +++++++ ...AppPlusWechatNativePayServiceImplTest.java | 168 +++ ...pPlusWechatServiceImplPayCallbackTest.java | 244 ++++ 57 files changed, 8346 insertions(+), 153 deletions(-) create mode 100644 db/20260805_add_third_aggregate_pay.sql create mode 100644 nacos_local.env create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAggregateOrderCodePayStatusVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAggregateOrderCodePayVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJob.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdAggregatePaySessionStatusEnum.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdAggregateOrderCodePayDto.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdOrderCodePayDto.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdAggregatePaySessionEntity.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateOrderCodePayStatusVo.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateOrderCodePayVo.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdOrderCodePayStatusVo.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdOrderCodePayVo.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AggregateWechatPayConfiguration.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayController.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJob.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdAggregatePaySessionMapper.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateAlipayOauthIdentity.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateCashierLaunch.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateCashierPageData.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregatePayCreateContext.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateAliPayService.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateCashierHtmlRenderer.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregatePaySessionService.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateWechatPayService.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusWechatNativePayService.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImpl.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatNativePayServiceImpl.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJobTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateCashierHtmlRendererTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImplTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatNativePayServiceImplTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java diff --git a/db/20260805_add_third_aggregate_pay.sql b/db/20260805_add_third_aggregate_pay.sql new file mode 100644 index 000000000..734e27f86 --- /dev/null +++ b/db/20260805_add_third_aggregate_pay.sql @@ -0,0 +1,37 @@ +-- 支付宝与微信聚合订单码支付。 +-- 聚合会话与实际第三方支付订单分表保存,避免改变已被多个调用方使用的 third_pay_order 契约。 +-- 微信统一使用服务商平台公众号配置和各商户既有 sub_mchid,不新增逐商户公众号配置表。 + +CREATE TABLE `third_aggregate_pay_session` +( + `id` bigint NOT NULL COMMENT '聚合会话主键', + `aggregate_no` varchar(64) NOT NULL COMMENT '聚合支付会话唯一编号', + `access_token_hash` char(64) NOT NULL COMMENT '收银URL随机访问令牌SHA-256摘要', + `access_token_ciphertext` varchar(512) NOT NULL COMMENT '收银URL随机访问令牌Jasypt密文,仅用于幂等复用', + `biz_type` varchar(32) NOT NULL COMMENT '支付业务类型编码,首版为YXFJ_PAY对应编码', + `biz_id` varchar(128) NOT NULL COMMENT '调用方业务订单唯一标识', + `user_id` bigint NOT NULL COMMENT '付款用户ID,匿名消费机订单为-1', + `mch_id` bigint NOT NULL COMMENT '收款商户ID', + `price` int NOT NULL COMMENT '应付金额,单位为人民币分', + `status` varchar(32) NOT NULL COMMENT '聚合会话状态编码', + `selected_platform` varchar(32) DEFAULT NULL COMMENT '首个有效扫码选定的支付平台编码', + `pay_no` varchar(128) DEFAULT NULL COMMENT '绑定的third_pay_order.pay_no', + `payer_identity_hash` char(64) DEFAULT NULL COMMENT '支付宝用户ID或微信服务商公众号sp_openid的SHA-256摘要', + `expire_time` datetime NOT NULL COMMENT '聚合二维码与第三方子订单服务端失效时间', + `resolved_time` datetime DEFAULT NULL COMMENT '会话进入成功、关闭、过期或创建失败终态的时间', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '修改人', + `update_time` datetime NOT NULL COMMENT '修改时间', + `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除', + `tenant_id` bigint NOT NULL COMMENT '租户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_third_aggregate_pay_no` (`tenant_id`, `aggregate_no`), + UNIQUE KEY `uk_third_aggregate_access_token` (`tenant_id`, `access_token_hash`), + UNIQUE KEY `uk_third_aggregate_child_pay_no` (`tenant_id`, `pay_no`), + KEY `idx_third_aggregate_biz` (`tenant_id`, `biz_type`, `biz_id`, `create_time`), + KEY `idx_third_aggregate_job` (`status`, `expire_time`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci + COMMENT = '支付宝与微信聚合订单码支付会话'; diff --git a/nacos_local.env b/nacos_local.env new file mode 100644 index 000000000..687edeeb4 --- /dev/null +++ b/nacos_local.env @@ -0,0 +1,6 @@ +MYSQL_HOST=127.0.0.1 +MYSQL_PORT=3306 +MYSQL_DB=pjcloudx_config + +MYSQL_USER=root +MYSQL_PWD=root \ No newline at end of file diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardPayTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardPayTypeEnum.java index 47425a572..4b62b2172 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardPayTypeEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardPayTypeEnum.java @@ -3,7 +3,6 @@ package com.bsd.pjcloud.hard.api.constant.order; import cn.hutool.core.util.ObjectUtil; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; -import lombok.AllArgsConstructor; import lombok.Getter; import java.util.Arrays; @@ -12,36 +11,60 @@ import java.util.List; /** * 硬件订单支付方式 */ -@AllArgsConstructor @Getter public enum HardPayTypeEnum { + /** 默认钱包余额支付。 */ WALLET_DEFAULT("1", "钱包支付"), + /** 用户向终端出示自有钱包二维码支付。 */ WALLET_QR_CODE("2", "钱包二维码支付"), + /** 旧版终端展示自有钱包订单二维码,由移动端扫码确认支付。 */ WALLET_QR_CODE_SCAN("22", "钱包扫一扫支付"), + /** 终端展示支付宝与微信共用的 HTTPS 聚合收银二维码。 */ + AGGREGATE_ORDER_PAY("23", "支付宝微信聚合码支付"), + /** 实体饭卡钱包支付。 */ WALLET_CARD("3", "钱包饭卡支付"), + /** 人脸识别钱包支付。 */ FACE("4", "人脸识别支付"), + /** 跨单位业务二维码支付。 */ STRIDE_DEPT_QR_CODE("5", "跨单位二维码支付"), + /** 外来人员票券二维码支付。 */ FOREIGN_TICKET_QR_CODE("6", "外来人员二维码支付"), + /** 线上预约订单二维码核销支付。 */ RESERVE_QR_CODE("7", "线上二维码支付"), + /** 终端线下现金支付。 */ CASH("8", "现金支付"), + /** 智能柜免密支付。 */ HARD_DOOR_FREE_PAY("9", "智能柜免密支付"), + /** 微信用户付款码支付。 */ WECHANT_SELF_QR_CODE_PAY("10", "微信付款码支付"), + /** 微信 App 支付。 */ WECHANT_APP_PAY("100", "微信App支付"), + /** 支付宝用户付款码支付。 */ ALI_SELF_QR_CODE_PAY("11", "支付宝付款码支付"), + /** 支付宝 App 支付。 */ ALI_APP_PAY("110", "支付宝App支付"), + /** 中医院渠道支付。 */ ZYY("12", "中医院"), + /** 人民医院渠道支付。 */ RMYY("13", "人民医院"), + /** 超市消费卡渠道支付。 */ CSXFK("14", "超市消费卡"), + /** 活动订单核销。 */ ACT_CANCEL("50", "活动核销"), ; + + /** 数据库及跨端协议使用的支付类型编码。 */ private final String type; + + /** 支付类型的中文业务说明。 */ private final String description; - // 支付方式交易记录 + /** 订单统计和管理端支付方式汇总需要展示的类型集合。 */ public static final List payTypeTotalDisplayList = List.of( WALLET_DEFAULT, WALLET_QR_CODE, WALLET_QR_CODE_SCAN, + AGGREGATE_ORDER_PAY, WALLET_CARD, FACE, CASH, @@ -52,22 +75,24 @@ public enum HardPayTypeEnum { CSXFK ); + /** 不允许进入钱包扣款逻辑的外部渠道支付类型集合。 */ public static final List isNotWalletPay = List.of( STRIDE_DEPT_QR_CODE, FOREIGN_TICKET_QR_CODE, + AGGREGATE_ORDER_PAY, WECHANT_SELF_QR_CODE_PAY, ALI_SELF_QR_CODE_PAY, WECHANT_APP_PAY, ALI_APP_PAY ); - // 可以核销的订单 + /** 支付完成后仍需要执行业务核销的二维码类型集合。 */ public static final List isNeedCancel = List.of( STRIDE_DEPT_QR_CODE, FOREIGN_TICKET_QR_CODE ); - // 线下支付的订单类型(现金支付,其他渠道支付如中医院、人民医院) + /** 无需在线支付平台确认的线下支付类型集合。 */ public static final List isOffline = List.of( CASH, ZYY, @@ -75,13 +100,30 @@ public enum HardPayTypeEnum { CSXFK ); - // 简单支付(没有购物车) + /** 不要求完整购物车业务链路的简单支付类型集合。 */ public static final List isSimplePayList = List.of( WALLET_QR_CODE_SCAN, ACT_CANCEL, WALLET_CARD ); + /** + * 创建一个 Hardware 支付方式枚举值。 + * + * @param type 数据库和接口使用的稳定编码 + * @param description 中文业务说明 + */ + HardPayTypeEnum(String type, String description) { + this.type = type; + this.description = description; + } + + /** + * 将 App 支付平台转换为 Hardware 订单支付方式。 + * + * @param appPlusPlatformTypeEnum App 支付平台 + * @return 对应支付方式;不支持的平台返回 {@code null} + */ public static HardPayTypeEnum transformHardPayTypeEnum(AppPlusPlatformTypeEnum appPlusPlatformTypeEnum) { switch (appPlusPlatformTypeEnum) { case WECHAT: @@ -95,6 +137,12 @@ public enum HardPayTypeEnum { } } + /** + * 按持久化编码解析支付方式。 + * + * @param type 数据库或接口支付类型编码 + * @return 匹配枚举;为空或未知时返回 {@code null} + */ public static HardPayTypeEnum valueOfByType(String type) { if (ObjectUtil.isEmpty(type)) return null; return Arrays.stream(values()).filter(v -> ObjectUtil.equals(type, v.getType())).findFirst().orElse(null); diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAggregateOrderCodePayStatusVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAggregateOrderCodePayStatusVo.java new file mode 100644 index 000000000..c53f5e662 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAggregateOrderCodePayStatusVo.java @@ -0,0 +1,52 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 收银终端聚合订单码支付状态。 + * + *

状态由 Hardware 服务核对聚合编号与业务订单绑定关系后返回,支付成功时已同步完成 + * Hardware 订单和库存的幂等更新。

+ */ +@Data +@Builder +@Schema(description = "收银终端聚合订单码支付状态") +public class HardAggregateOrderCodePayStatusVo { + + /** Hardware 服务业务订单 ID。 */ + @Schema(description = "硬件业务订单ID") + private Long orderId; + + /** 与硬件订单 {@code order_no} 严格绑定的聚合会话编号。 */ + @Schema(description = "聚合支付会话编号") + private String aggregateNo; + + /** 聚合支付会话状态编码。 */ + @Schema(description = "聚合支付会话状态编码") + private String status; + + /** 首个有效扫码选定的平台,尚未扫码时为空。 */ + @Schema(description = "选定支付平台") + private AppPlusPlatformTypeEnum selectedPlatform; + + /** 聚合会话创建的支付宝或微信实际支付订单号,尚未创建时为空。 */ + @Schema(description = "第三方支付子订单号") + private String payNo; + + /** 是否已由支付平台确认成功并同步完成 Hardware 订单。 */ + @Schema(description = "是否支付成功") + private Boolean paySuccess; + + /** 聚合二维码服务端失效时间,使用服务端本地时区。 */ + @Schema(description = "聚合二维码失效时间") + private LocalDateTime expireTime; + + /** Hardware 业务订单当前状态编码。 */ + @Schema(description = "硬件业务订单状态编码") + private String orderStatus; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAggregateOrderCodePayVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAggregateOrderCodePayVo.java new file mode 100644 index 000000000..d4b86503c --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAggregateOrderCodePayVo.java @@ -0,0 +1,42 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 收银终端支付宝与微信聚合订单码创建结果。 + * + *

该模型只承载聚合收银地址,不得用于自有钱包二维码或平台原生支付二维码。

+ */ +@Data +@Builder +@Schema(description = "收银终端聚合订单码创建结果") +public class HardAggregateOrderCodePayVo { + + /** Hardware 服务创建的业务订单 ID,用于后续聚合支付状态查询。 */ + @Schema(description = "硬件业务订单ID") + private Long orderId; + + /** 收款商户 ID,与创建请求中的终端绑定商户一致。 */ + @Schema(description = "收款商户ID") + private Long mchId; + + /** Thirdparty 服务生成并绑定到硬件订单的聚合会话编号。 */ + @Schema(description = "聚合支付会话编号") + private String aggregateNo; + + /** 支付宝和微信均可扫码访问的 HTTPS 聚合收银地址。 */ + @Schema(description = "聚合二维码内容串") + private String qrCode; + + /** 二维码协议类型,固定为 {@code AGGREGATE_ORDER_PAY}。 */ + @Schema(description = "二维码协议类型") + private String qrCodeType; + + /** Thirdparty 服务确定的聚合会话过期时间,使用服务端本地时区。 */ + @Schema(description = "聚合二维码失效时间") + private LocalDateTime expireTime; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java index decc8b48c..6f6b731e0 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java @@ -21,6 +21,8 @@ import com.bsd.pjcloud.hard.api.vo.android.HardAdUserVo; import com.bsd.pjcloud.hard.api.vo.android.ReservationMenuQueryVo; import com.bsd.pjcloud.hard.api.vo.android.VerificationRecordVo; import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; +import com.bsd.pjcloud.hard.api.vo.order.HardAggregateOrderCodePayStatusVo; +import com.bsd.pjcloud.hard.api.vo.order.HardAggregateOrderCodePayVo; import com.bsd.pjcloud.hard.api.vo.order.HardCreateHardQrCodeScanOrderVo; import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; import com.bsd.pjcloud.hard.service.*; @@ -134,6 +136,45 @@ public class HardApiController { return R.ok(hardwareOrderService.createHardQrCodeScanOrder(dto)); } + /** + * 创建支付宝与微信共用的聚合订单码,同时保留旧自有钱包扫码接口的原有语义。 + * + * @param dto 终端商品、设备和商户订单参数 + * @return Hardware 订单及其绑定的聚合二维码 + */ + @PostMapping("/create-aggregate-order-code-pay") + @Operation(summary = "创建支付宝微信聚合订单码") + @Idempotent(expireTime = 10, info = "正在创建聚合支付订单,请勿重复操作") + public R createAggregateOrderCodePay(@Valid @RequestBody HardCreateOrderDto dto) { + // 商品性质必须继续使用 Hardware 现有规范化规则,避免聚合支付订单和普通订单价格口径不同。 + if (ObjectUtil.isNotEmpty(dto.getList())) { + for (HardCreateCarDto item : dto.getList()) { + if (ObjectUtil.isEmpty(item.getProductNature())) continue; + if (ObjectUtil.isNotEmpty(item.getProductWeight()) && item.getProductWeight().compareTo(BigDecimal.ZERO) != 0) { + item.setProductNature(HardProductNatureEnum.WEIGHT); + } else if (ObjectUtil.isNotEmpty(item.getProductCount())) { + item.setProductNature(HardProductNatureEnum.DEFAULT); + } + } + } + dto.setOrderPayType(HardPayTypeEnum.AGGREGATE_ORDER_PAY); + return R.ok(hardwareOrderService.createAggregateOrderCodePay(dto)); + } + + /** + * 查询聚合订单码状态,支付成功时由 Service 在订单锁内完成业务订单结算。 + * + * @param orderId Hardware 业务订单 ID + * @param aggregateNo 创建接口返回的聚合会话编号 + * @return 聚合会话及 Hardware 订单状态 + */ + @GetMapping("/aggregate-order-code-pay/status") + @Operation(summary = "查询支付宝微信聚合订单码状态") + public R getAggregateOrderCodePayStatus( + @RequestParam Long orderId, @RequestParam String aggregateNo) { + return R.ok(hardwareOrderService.getAggregateOrderCodePayStatus(orderId, aggregateNo)); + } + @PostMapping("/done-hard-qr-code-order") @Operation(summary = "用户确认设备二维码扫一扫订单") @Idempotent(expireTime = 10, info = "订单支付中,请勿重复操作") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJob.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJob.java new file mode 100644 index 000000000..3af02e996 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJob.java @@ -0,0 +1,69 @@ +package com.bsd.pjcloud.hard.job; + +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; +import com.bsd.pjcloud.hard.service.HardOrderService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 消费机聚合订单码支付业务结算补偿任务。 + * + *

终端主动轮询仍是实时结算入口;本任务负责覆盖收银弹窗提前关闭、终端断网或进程退出后, + * 第三方已经支付但 Hardware 业务订单尚未结算的场景。

+ */ +@Component +@Slf4j +@RequiredArgsConstructor +public class HardAggregateOrderPayJob { + + /** + * 单轮最多处理的待结算订单数,避免跨服务查单长期占用调度线程。 + */ + private static final int BATCH_SIZE = 100; + + /** + * Hardware 订单服务,复用带订单锁和事务的聚合支付状态确认及结算流程。 + */ + private final HardOrderService hardOrderService; + + /** + * 每五秒补偿仍处于创建状态且已绑定聚合会话的 Hardware 订单。 + * + *

调度线程无租户上下文时先跨租户读取候选记录,再按记录持久化的租户 ID 恢复上下文; + * 具体结算继续由业务服务的分布式订单锁和状态条件保证幂等。

+ */ + @Scheduled(fixedDelay = 5000L, initialDelay = 5000L) + public void settlePendingAggregateOrders() { + List orders = hardOrderService.list(new LambdaQueryWrapper() + .eq(HardOrderEntity::getPayType, HardPayTypeEnum.AGGREGATE_ORDER_PAY.getType()) + .eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.CREATE.getType()) + .isNotNull(HardOrderEntity::getOrderNo) + .orderByAsc(HardOrderEntity::getCreateTime) + .last("limit " + BATCH_SIZE)); + if (ObjectUtil.isEmpty(orders)) { + return; + } + for (HardOrderEntity order : orders) { + if (order.getTenantId() == null) { + log.warn("聚合支付Hardware订单缺少持久化租户ID,orderId:{}", order.getId()); + continue; + } + try { + TenantBroker.runAs(order.getTenantId(), tenantId -> + hardOrderService.getAggregateOrderCodePayStatus(order.getId(), order.getOrderNo())); + } catch (Exception exception) { + log.warn("补偿聚合支付Hardware订单失败,orderId:{},tenantId:{},exceptionType:{}", + order.getId(), order.getTenantId(), exception.getClass().getSimpleName()); + } + } + } +} 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 16e5a0e15..b7e62933b 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 @@ -29,6 +29,8 @@ import com.bsd.pjcloud.hard.api.dto.order.xfj.HardXfjFastCreateOrderDto; import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import com.bsd.pjcloud.hard.api.vo.order.HardCreateHardQrCodeScanOrderVo; +import com.bsd.pjcloud.hard.api.vo.order.HardAggregateOrderCodePayStatusVo; +import com.bsd.pjcloud.hard.api.vo.order.HardAggregateOrderCodePayVo; import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; import com.bsd.pjcloud.hard.api.vo.order.HardPendingReservationCountVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateShopOrderVo; @@ -120,6 +122,23 @@ public interface HardOrderService extends IService { */ HardCreateHardQrCodeScanOrderVo createHardQrCodeScanOrder(@Valid HardCreateOrderDto dto); + /** + * 创建与 Hardware 业务订单绑定的支付宝、微信聚合订单码。 + * + * @param dto 终端商品、商户、设备及订单上下文 + * @return Hardware 订单 ID、聚合会话编号、HTTPS 二维码内容和过期时间 + */ + HardAggregateOrderCodePayVo createAggregateOrderCodePay(@Valid HardCreateOrderDto dto); + + /** + * 查询聚合码状态,并在第三方确认成功时幂等完成 Hardware 订单。 + * + * @param orderId Hardware 业务订单 ID + * @param aggregateNo 创建接口返回的聚合会话编号 + * @return 聚合会话和 Hardware 订单的最小状态 + */ + HardAggregateOrderCodePayStatusVo getAggregateOrderCodePayStatus(Long orderId, String aggregateNo); + /** * 用户确认设备二维码扫一扫订单 */ 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 874df6023..2d1b74efa 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 @@ -9,6 +9,7 @@ import com.baomidou.lock.LockInfo; import com.baomidou.lock.LockTemplate; import com.baomidou.lock.annotation.Lock4j; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bsd.pjcloud.admin.api.dto.UserInfo; @@ -77,6 +78,8 @@ import com.bsd.pjcloud.hard.api.utils.HardUtil; import com.bsd.pjcloud.hard.api.vo.HardDoorFlowVo; import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo; import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; +import com.bsd.pjcloud.hard.api.vo.order.HardAggregateOrderCodePayStatusVo; +import com.bsd.pjcloud.hard.api.vo.order.HardAggregateOrderCodePayVo; import com.bsd.pjcloud.hard.api.vo.order.HardCreateHardQrCodeScanOrderVo; import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; import com.bsd.pjcloud.hard.api.vo.order.HardPendingReservationCountVo; @@ -97,10 +100,14 @@ import com.bsd.pjcloud.supermarket.dto.SupermarketSpuUpdateStockReqDTO; import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppPlusPayService; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum; +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto; import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMicropayDto; import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMiniRemotePayDto; import com.bsd.pjcloud.thirdparty.dto.ks.KsRecognitionCallbackDto; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdMiniAppPayVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo; import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum; import com.bsd.pjcloud.wallet.api.constant.WalletCodeBizEnum; import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum; @@ -163,6 +170,12 @@ public class HardOrderServiceImpl extends ServiceImpl聚合会话编号持久化到现有 {@code hard_order.order_no},既保证服务重启后仍可核对绑定, + * 又不把自有钱包二维码的 URL 或状态复用到第三方聚合支付。

+ * + * @param dto 终端商品、商户和设备订单参数 + * @return Hardware 订单 ID、聚合会话编号、HTTPS 收银地址和过期时间 + */ + @Override + @Lock4j(keys = {"#dto.androidOrderCode"}) + @Transactional(rollbackFor = Exception.class) + public HardAggregateOrderCodePayVo createAggregateOrderCodePay(HardCreateOrderDto dto) { + MchManageVo mch = validateCreateOrderDto(dto); + if (ObjectUtil.isEmpty(mch)) { + throw new RuntimeException("机器绑定的商户不存在"); + } + + String subject = hardCarService.computerCarSubject(dto.getSysType(), dto.getOrderType(), dto.getList()); + int totalPrice = hardCarService.computerCarTotalPrice(dto.getSysType(), dto.getOrderType(), dto.getList()); + int extraPrice = isExtraPrice(dto.getSysType()) ? computeExtraPrice(totalPrice, mch, null) : 0; + int amount = totalPrice + extraPrice; + if (amount <= 0) { + throw new RuntimeException("聚合支付金额必须大于0"); + } + + HardOrderEntity reusableOrder = getReusableAggregateOrder(dto); + if (reusableOrder != null) { + validateReusableAggregateOrder(reusableOrder, dto, totalPrice, extraPrice, subject); + return createOrReuseAggregateSession(reusableOrder, amount); + } + + Long parentCarId = hardCarService.createCard(dto.getUserId(), dto.getMerchantId(), dto.getSysType(), dto.getOrderType(), dto.getList()); + HardOrderEntity order = HardOrderEntity.builder() + .androidOrderCode(dto.getAndroidOrderCode()) + .sysType(dto.getSysType().getType()) + .hourType(String.valueOf(dto.getHourType())) + .parentCarId(parentCarId) + .hardId(dto.getHardwareId()) + .orderStatus(HardOrderStatusEnum.CREATE.getType()) + .merchantId(dto.getMerchantId()) + .merchantType(mch.getTypes()) + .merchantDeptId(mch.getDeptId()) + .deptId(dto.getDeptId()) + .orderType(dto.getOrderType().getType()) + .payType(HardPayTypeEnum.AGGREGATE_ORDER_PAY.getType()) + .orderSourceTypeId(dto.getOrderSourceTypeId()) + .totalPrice(totalPrice) + .extraPrice(extraPrice) + .payPrice(0) + .isOfflineOrder(YesNoEnum.NO.getCode()) + .apptUseDate(LocalDate.now()) + .productDesc(subject) + .userId(-1L) + .build(); + if (baseMapper.insert(order) <= 0 || order.getId() == null) { + throw new RuntimeException("创建聚合支付业务订单失败"); + } + + return createOrReuseAggregateSession(order, amount); + } + + /** + * 查询同一终端本地订单号尚未结束的聚合支付订单,供创建响应丢失后的重试复用。 + * + * @param dto 当前收银端创建请求 + * @return 同设备、同商户的待支付或已支付聚合订单;不存在时返回 {@code null} + */ + private HardOrderEntity getReusableAggregateOrder(HardCreateOrderDto dto) { + return getOne(new LambdaQueryWrapper() + .eq(HardOrderEntity::getAndroidOrderCode, dto.getAndroidOrderCode()) + .eq(HardOrderEntity::getHardId, dto.getHardwareId()) + .eq(HardOrderEntity::getMerchantId, dto.getMerchantId()) + .eq(HardOrderEntity::getPayType, HardPayTypeEnum.AGGREGATE_ORDER_PAY.getType()) + .in(HardOrderEntity::getOrderStatus, + HardOrderStatusEnum.CREATE.getType(), HardOrderStatusEnum.PAY.getType()) + .orderByDesc(HardOrderEntity::getCreateTime) + .last("limit 1")); + } + + /** + * 校验可复用 Hardware 订单与当前请求的业务、商品摘要和金额完全一致。 + * + * @param order 已存在的聚合支付 Hardware 订单 + * @param dto 当前收银端创建请求 + * @param totalPrice 当前购物车商品总金额,单位为分 + * @param extraPrice 当前额外金额,单位为分 + * @param subject 当前购物车商品摘要 + */ + private void validateReusableAggregateOrder(HardOrderEntity order, HardCreateOrderDto dto, int totalPrice, + int extraPrice, String subject) { + if (Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType())) { + throw new RuntimeException("该终端订单已支付,请勿重复收款"); + } + if (!Objects.equals(order.getSysType(), dto.getSysType().getType()) + || !Objects.equals(order.getOrderType(), dto.getOrderType().getType()) + || !Objects.equals(order.getTotalPrice(), totalPrice) + || !Objects.equals(order.getExtraPrice(), extraPrice) + || !Objects.equals(order.getProductDesc(), subject)) { + throw new RuntimeException("该终端订单号已存在不同内容的待支付订单"); + } + } + + /** + * 为已持久化的 Hardware 订单创建或复用 Thirdparty 聚合会话,并原子更新会话编号绑定。 + * + * @param order 待支付 Hardware 业务订单 + * @param amount 应付金额,单位为分 + * @return Hardware 订单及其聚合收银二维码 + */ + private HardAggregateOrderCodePayVo createOrReuseAggregateSession(HardOrderEntity order, int amount) { + + ThirdAggregateOrderCodePayDto aggregateDto = new ThirdAggregateOrderCodePayDto(); + aggregateDto.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY); + aggregateDto.setBizId(String.valueOf(order.getId())); + aggregateDto.setMchId(order.getMerchantId()); + aggregateDto.setAmount(amount); + aggregateDto.setUserId(null); + R aggregateResult = thirdAppPlusPayService.aggregateOrderCodePay(aggregateDto); + if (aggregateResult == null || !aggregateResult.isOk() || aggregateResult.getData() == null) { + String message = aggregateResult == null ? "第三方支付服务无响应" : aggregateResult.getMsg(); + throw new RuntimeException("创建聚合订单码失败:" + message); + } + + ThirdAggregateOrderCodePayVo aggregate = aggregateResult.getData(); + if (!ObjectUtil.isAllNotEmpty(aggregate.getAggregateNo(), aggregate.getQrCode(), aggregate.getQrCodeType(), aggregate.getExpireTime()) + || !Objects.equals(aggregate.getQrCodeType(), AGGREGATE_ORDER_PAY_QR_CODE_TYPE) + || !StrUtil.startWithIgnoreCase(aggregate.getQrCode(), "https://") + || !aggregate.getQrCode().contains(AGGREGATE_ORDER_PAY_CASHIER_PATH)) { + throw new RuntimeException("创建聚合订单码失败:第三方支付返回不完整"); + } + if (!Objects.equals(order.getOrderNo(), aggregate.getAggregateNo())) { + LambdaUpdateWrapper bindWrapper = new LambdaUpdateWrapper() + .eq(HardOrderEntity::getId, order.getId()) + .eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.CREATE.getType()) + .eq(HardOrderEntity::getPayType, HardPayTypeEnum.AGGREGATE_ORDER_PAY.getType()); + if (StrUtil.isBlank(order.getOrderNo())) { + bindWrapper.isNull(HardOrderEntity::getOrderNo); + } else { + bindWrapper.eq(HardOrderEntity::getOrderNo, order.getOrderNo()); + } + if (!update(bindWrapper.set(HardOrderEntity::getOrderNo, aggregate.getAggregateNo()))) { + throw new RuntimeException("绑定聚合支付会话失败"); + } + } + return HardAggregateOrderCodePayVo.builder() + .orderId(order.getId()) + .mchId(order.getMerchantId()) + .aggregateNo(aggregate.getAggregateNo()) + .qrCode(aggregate.getQrCode()) + .qrCodeType(aggregate.getQrCodeType()) + .expireTime(aggregate.getExpireTime()) + .build(); + } + + /** + * 查询聚合支付状态,并在支付平台确认成功后幂等完成 Hardware 订单。 + * + *

订单 ID 和聚合编号必须同时匹配持久化绑定;订单锁与未支付条件共同防止终端轮询、 + * 第三方回调同步和重复请求造成库存重复扣减。

+ * + * @param orderId Hardware 业务订单 ID + * @param aggregateNo 创建接口返回的聚合会话编号 + * @return 聚合会话和 Hardware 订单的最小状态 + */ + @Override + @Lock4j(keys = {"#orderId"}) + @Transactional(rollbackFor = Exception.class) + public HardAggregateOrderCodePayStatusVo getAggregateOrderCodePayStatus(Long orderId, String aggregateNo) { + if (!ObjectUtil.isAllNotEmpty(orderId, aggregateNo)) { + throw new RuntimeException("聚合支付订单参数不完整"); + } + HardOrderEntity order = baseMapper.selectById(orderId); + if (order == null + || !Objects.equals(order.getPayType(), HardPayTypeEnum.AGGREGATE_ORDER_PAY.getType()) + || !Objects.equals(order.getOrderNo(), aggregateNo)) { + throw new RuntimeException("聚合支付订单不存在或绑定关系不匹配"); + } + if (Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType())) { + return HardAggregateOrderCodePayStatusVo.builder() + .orderId(order.getId()) + .aggregateNo(order.getOrderNo()) + .status(ThirdAggregatePaySessionStatusEnum.PAY_SUCCESS.getType()) + .paySuccess(Boolean.TRUE) + .orderStatus(order.getOrderStatus()) + .build(); + } + if (Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.UNFOLLOW.getType())) { + // 终态响应丢失后重复查询时仍返回明确关闭结果,避免终端把业务终态当作临时网络异常持续轮询。 + return HardAggregateOrderCodePayStatusVo.builder() + .orderId(order.getId()) + .aggregateNo(order.getOrderNo()) + .status(ThirdAggregatePaySessionStatusEnum.PAY_CLOSED.getType()) + .paySuccess(Boolean.FALSE) + .orderStatus(order.getOrderStatus()) + .build(); + } + if (!Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.CREATE.getType())) { + throw new RuntimeException("聚合支付业务订单已关闭"); + } + + R statusResult = thirdAppPlusPayService.getAggregateOrderCodePayStatus(aggregateNo); + if (statusResult == null || !statusResult.isOk() || statusResult.getData() == null) { + String message = statusResult == null ? "第三方支付服务无响应" : statusResult.getMsg(); + throw new RuntimeException("查询聚合订单码失败:" + message); + } + ThirdAggregateOrderCodePayStatusVo aggregateStatus = statusResult.getData(); + if (!Objects.equals(aggregateNo, aggregateStatus.getAggregateNo())) { + throw new RuntimeException("聚合支付状态绑定关系不匹配"); + } + + if (Boolean.TRUE.equals(aggregateStatus.getPaySuccess())) { + // 只有支付平台明确成功后才扣库存;订单锁防止正常重复轮询,库存远程调用仍沿用现有跨服务一致性边界。 + getSelf().fastenerInventory(order, YesNoEnum.YES); + getSelf().orderSuccess(order, Boolean.TRUE); + order.setOrderStatus(HardOrderStatusEnum.PAY.getType()); + order.setPayPrice(order.getTotalPrice() + order.getExtraPrice()); + if (TransactionSynchronizationManager.isSynchronizationActive()) { + Long paidOrderId = order.getId(); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** 事务提交后再触发现有打印/MQ流程,避免数据库回滚但外部副作用已经发生。 */ + @Override + public void afterCommit() { + createOrderAfterSendMqMsg(paidOrderId, null); + } + }); + } + } else if (List.of( + ThirdAggregatePaySessionStatusEnum.PAY_CLOSED.getType(), + ThirdAggregatePaySessionStatusEnum.EXPIRED.getType(), + ThirdAggregatePaySessionStatusEnum.CREATE_FAILED.getType()).contains(aggregateStatus.getStatus())) { + // 终态失败订单不再保留为“未支付”,终端重试会创建新的 Hardware 订单和聚合会话。 + boolean closed = update(new LambdaUpdateWrapper() + .eq(HardOrderEntity::getId, order.getId()) + .eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.CREATE.getType()) + .set(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.UNFOLLOW.getType()) + .set(HardOrderEntity::getUnfollowTime, LocalDateTime.now())); + if (closed) { + order.setOrderStatus(HardOrderStatusEnum.UNFOLLOW.getType()); + } + } + + return HardAggregateOrderCodePayStatusVo.builder() + .orderId(order.getId()) + .aggregateNo(aggregateStatus.getAggregateNo()) + .status(aggregateStatus.getStatus()) + .selectedPlatform(aggregateStatus.getSelectedPlatform()) + .payNo(aggregateStatus.getPayNo()) + .paySuccess(aggregateStatus.getPaySuccess()) + .expireTime(aggregateStatus.getExpireTime()) + .orderStatus(order.getOrderStatus()) + .build(); + } + /** * 用户确认设备二维码扫一扫订单 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdAppPlusPayService.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdAppPlusPayService.java index 89a777651..70d88df11 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdAppPlusPayService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoterThirdAppPlusPayService.java @@ -5,14 +5,25 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.feign.annotation.NoToken; import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMicropayDto; import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMiniRemotePayDto; +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto; +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdOrderCodePayDto; import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdRemoterRefundDto; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdMiniAppPayVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayStatusVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import jakarta.validation.Valid; import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; - +/** + * 第三方支付服务内部 Feign 契约,复用统一内部调用头和服务发现配置。 + */ @FeignClient(contextId = "remoterThirdAppPlusPayService", value = ServiceNameConstants.THIRDPARTY_SERVICE) public interface RemoterThirdAppPlusPayService { @@ -30,6 +41,46 @@ public interface RemoterThirdAppPlusPayService { @PostMapping("/pay-remoter/micropay") R remoterMicropay(@RequestBody ThirdMicropayDto dto); + /** + * 创建支付宝或微信订单码支付订单。 + * + * @param dto 可信内部调用方提供的业务、商户和金额信息 + * @return 支付订单号、二维码内容串和服务端过期时间 + */ + @NoToken + @PostMapping("/pay-remoter/order-code-pay") + R orderCodePay(@Valid @RequestBody ThirdOrderCodePayDto dto); + + /** + * 查询订单码支付的最小状态信息。 + * + * @param payNo 第三方支付服务生成的支付订单号 + * @return 内部支付状态、成功标识和过期时间 + */ + @NoToken + @GetMapping("/pay-remoter/order-code-pay/status") + R getOrderCodePayStatus(@RequestParam("payNo") String payNo); + + /** + * 创建支付宝与微信共用的聚合订单二维码。 + * + * @param dto 业务订单、商户和金额参数,不指定支付平台 + * @return 聚合会话编号、HTTPS 二维码内容和失效时间 + */ + @PostMapping("/pay-remoter/aggregate-order-code-pay") + @NoToken + R aggregateOrderCodePay(@Valid @RequestBody ThirdAggregateOrderCodePayDto dto); + + /** + * 按聚合会话编号查询最小支付状态。 + * + * @param aggregateNo 聚合支付会话编号 + * @return 聚合状态、选定平台和实际第三方子订单号 + */ + @GetMapping("/pay-remoter/aggregate-order-code-pay/status") + @NoToken + R getAggregateOrderCodePayStatus(@RequestParam("aggregateNo") String aggregateNo); + /** * app支付 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdAggregatePaySessionStatusEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdAggregatePaySessionStatusEnum.java new file mode 100644 index 000000000..d6a55dd7e --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdAggregatePaySessionStatusEnum.java @@ -0,0 +1,101 @@ +package com.bsd.pjcloud.thirdparty.constant.pay; + +import cn.hutool.core.util.ObjectUtil; + +import java.util.Arrays; + +/** + * 聚合订单码支付会话状态。 + * + *

状态编码持久化到 {@code third_aggregate_pay_session.status},用于约束扫码选平台、 + * 第三方子订单创建以及最终支付结果同步的单向状态流转。

+ */ +public enum ThirdAggregatePaySessionStatusEnum { + + /** + * 聚合码已经创建,尚未有支付宝或微信完成付款人授权。 + */ + WAIT_SCAN("10", "等待扫码"), + + /** + * 首个扫码平台已经锁定,正在创建对应平台的 JSAPI 子订单。 + */ + PLATFORM_CREATING("15", "平台订单创建中"), + + /** + * 第三方 JSAPI 子订单创建成功,正在等待付款结果。 + */ + PAYING("20", "支付中"), + + /** + * 绑定的第三方子订单已经确认支付成功。 + */ + PAY_SUCCESS("30", "支付成功"), + + /** + * 绑定的第三方子订单已被支付平台关闭或撤销。 + */ + PAY_CLOSED("40", "交易已关闭"), + + /** + * 聚合会话在未产生成功支付结果前超过服务端有效期。 + */ + EXPIRED("50", "已过期"), + + /** + * 选定平台后创建第三方子订单失败,当前聚合码不可继续支付。 + */ + CREATE_FAILED("60", "创建失败"); + + /** + * 持久化到聚合支付会话表并通过状态接口返回的协议编码。 + */ + private final String type; + + /** + * 用于管理端和诊断信息展示的中文状态说明。 + */ + private final String description; + + /** + * 创建一个聚合支付会话状态枚举值。 + * + * @param type 持久化状态编码 + * @param description 中文状态说明 + */ + ThirdAggregatePaySessionStatusEnum(String type, String description) { + this.type = type; + this.description = description; + } + + /** + * 获取持久化状态编码。 + * + * @return 聚合支付会话状态编码 + */ + public String getType() { + return type; + } + + /** + * 获取中文状态说明。 + * + * @return 聚合支付会话状态说明 + */ + public String getDescription() { + return description; + } + + /** + * 按持久化编码解析聚合支付会话状态。 + * + * @param type 数据库或接口中的状态编码,允许为空 + * @return 匹配状态;编码为空或未知时返回 {@code null} + */ + public static ThirdAggregatePaySessionStatusEnum valueOfByType(String type) { + if (ObjectUtil.isEmpty(type)) { + return null; + } + return Arrays.stream(values()).filter(value -> ObjectUtil.equals(value.type, type)).findFirst().orElse(null); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPayOrderStatusEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPayOrderStatusEnum.java index 239e4804c..e6b4916d2 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPayOrderStatusEnum.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPayOrderStatusEnum.java @@ -1,32 +1,100 @@ package com.bsd.pjcloud.thirdparty.constant.pay; import cn.hutool.core.util.ObjectUtil; -import lombok.AllArgsConstructor; -import lombok.Getter; import java.util.Arrays; /** - * 第三方支付订单状态 + * 第三方支付订单状态。 + * + *

状态编码会持久化到 {@code third_pay_order.status},不得随意修改已有编码。

*/ -@AllArgsConstructor -@Getter public enum ThirdPayOrderStatusEnum { + + /** + * 支付订单已创建,尚未确认买家付款。 + */ PAY_UNPAID("10", "未支付"), + + /** + * 支付平台已经确认买家付款成功。 + */ PAY_SUCCESS("20", "支付成功"), + + /** + * 创建支付或支付处理明确失败,不能继续使用当前支付订单。 + */ PAY_FAILED("30", "支付失败"), + /** + * 支付平台已关闭交易,包括二维码超时后撤销成功的场景。 + */ + PAY_CLOSED("40", "交易已关闭"), + + /** + * 退款请求已受理,最终退款结果尚未确认。 + */ REFUNDING("100", "退款中"), + + /** + * 退款已经成功完成。 + */ REFUND_SUCCESS("200", "退款成功"), + + /** + * 退款处理明确失败。 + */ REFUND_FAILED("300", "退款失败"), ; + /** + * 持久化到支付订单表的状态编码,也是对外状态查询返回的协议值。 + */ private final String type; + + /** + * 用于管理端和诊断信息展示的中文状态说明。 + */ private final String description; + /** + * 创建一个支付订单状态枚举值。 + * + * @param type 持久化状态编码 + * @param description 中文状态说明 + */ + ThirdPayOrderStatusEnum(String type, String description) { + this.type = type; + this.description = description; + } + + /** + * 获取持久化状态编码。 + * + * @return 支付订单状态编码 + */ + public String getType() { + return type; + } + + /** + * 获取状态的中文说明。 + * + * @return 中文状态说明 + */ + public String getDescription() { + return description; + } + + /** + * 按持久化状态编码解析枚举。 + * + * @param type 支付订单状态编码,允许为空 + * @return 匹配的状态;编码为空或未知时返回 {@code null} + */ public static ThirdPayOrderStatusEnum valueOfByType(String type) { if (ObjectUtil.isEmpty(type)) return null; - return Arrays.stream(values()).filter(v -> ObjectUtil.equals(type, v.getType())).findFirst().get(); + return Arrays.stream(values()).filter(v -> ObjectUtil.equals(type, v.getType())).findFirst().orElse(null); } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdAggregateOrderCodePayDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdAggregateOrderCodePayDto.java new file mode 100644 index 000000000..6bd578841 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdAggregateOrderCodePayDto.java @@ -0,0 +1,53 @@ +package com.bsd.pjcloud.thirdparty.dto.appplus; + +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Data; + +/** + * 支付宝与微信聚合订单码创建请求。 + * + *

请求不指定支付平台,实际平台由付款人使用支付宝或微信扫码后在服务端原子选定。

+ */ +@Data +@Schema(description = "支付宝与微信聚合订单码创建请求") +public class ThirdAggregateOrderCodePayDto { + + /** + * 支付业务类型,首版必须为 {@link ThirdPayOrderBizTypeEnum#YXFJ_PAY}。 + */ + @NotNull(message = "支付业务类型不能为空") + @Schema(description = "支付业务类型", requiredMode = Schema.RequiredMode.REQUIRED) + private ThirdPayOrderBizTypeEnum bizType; + + /** + * 调用方业务订单唯一标识,用于同一租户内的支付幂等和跨二维码互斥。 + */ + @NotBlank(message = "支付业务id不能为空") + @Schema(description = "支付业务id", requiredMode = Schema.RequiredMode.REQUIRED) + private String bizId; + + /** + * 收款商户ID,用于读取商户支付宝授权和微信支付子商户号配置。 + */ + @NotNull(message = "商户id不能为空") + @Schema(description = "收款商户ID", requiredMode = Schema.RequiredMode.REQUIRED) + private Long mchId; + + /** + * 应付金额,单位为人民币分,必须为正整数。 + */ + @NotNull(message = "支付金额不能为空") + @Positive(message = "支付金额必须大于0") + @Schema(description = "支付金额,单位为分", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer amount; + + /** + * 付款用户ID;匿名消费机订单可不传,服务端统一按 {@code -1L} 保存。 + */ + @Schema(description = "付款用户ID,匿名订单可为空") + private Long userId; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdOrderCodePayDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdOrderCodePayDto.java new file mode 100644 index 000000000..923dac7ec --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdOrderCodePayDto.java @@ -0,0 +1,62 @@ +package com.bsd.pjcloud.thirdparty.dto.appplus; + +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Data; + +/** + * 订单码支付创建请求。 + * + *

接受云消费机支付宝或微信订单码,金额由可信内部调用方按人民币分传入。

+ */ +@Data +@Schema(description = "订单码支付创建请求") +public class ThirdOrderCodePayDto { + + /** + * 支付业务类型,必须为 {@link ThirdPayOrderBizTypeEnum#YXFJ_PAY}。 + */ + @NotNull(message = "支付业务类型不能为空") + @Schema(description = "支付业务类型", requiredMode = Schema.RequiredMode.REQUIRED) + private ThirdPayOrderBizTypeEnum bizType; + + /** + * 调用方业务订单唯一标识,用于同一租户内的支付幂等控制。 + */ + @NotBlank(message = "支付业务id不能为空") + @Schema(description = "支付业务id", requiredMode = Schema.RequiredMode.REQUIRED) + private String bizId; + + /** + * 扫码支付平台,必须为 {@link AppPlusPlatformTypeEnum#ALI_PAY} 或 + * {@link AppPlusPlatformTypeEnum#WECHAT}。 + */ + @NotNull(message = "扫码支付平台不能为空") + @Schema(description = "扫码支付平台", requiredMode = Schema.RequiredMode.REQUIRED) + private AppPlusPlatformTypeEnum miniPlatformType; + + /** + * 应付金额,单位为人民币分,必须为正整数。 + */ + @NotNull(message = "支付金额不能为空") + @Positive(message = "支付金额必须大于0") + @Schema(description = "支付金额,单位为分", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer amount; + + /** + * 付款用户ID;匿名消费机订单可不传,服务端统一按 {@code -1L} 保存。 + */ + @Schema(description = "付款用户ID,匿名订单可为空") + private Long userId; + + /** + * 收款商户ID,服务端仅按该标识读取商户及其启用的目标平台配置。 + */ + @NotNull(message = "商户id不能为空") + @Schema(description = "收款商户ID", requiredMode = Schema.RequiredMode.REQUIRED) + private Long mchId; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdAggregatePaySessionEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdAggregatePaySessionEntity.java new file mode 100644 index 000000000..729896e6a --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdAggregatePaySessionEntity.java @@ -0,0 +1,162 @@ +package com.bsd.pjcloud.thirdparty.entity.pay; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 支付宝与微信聚合订单码会话。 + * + *

会话只负责跨平台扫码选择、访问令牌和第三方子订单绑定,实际支付报文继续由 + * {@code third_pay_order} 保存。

+ */ +@Data +@TenantTable +@TableName("third_aggregate_pay_session") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "支付宝与微信聚合订单码会话") +public class ThirdAggregatePaySessionEntity extends Model { + + /** + * 聚合会话主键,由 MyBatis-Plus 分布式ID生成器写入。 + */ + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "主键") + private Long id; + + /** + * 聚合会话唯一编号,供内部创建和状态查询接口使用。 + */ + @Schema(description = "聚合会话编号") + private String aggregateNo; + + /** + * 收银 URL 随机访问令牌的 SHA-256 十六进制摘要,用于扫码入口等值查询。 + */ + @Schema(description = "访问令牌SHA-256摘要") + private String accessTokenHash; + + /** + * 收银 URL 随机访问令牌的 Jasypt 密文,仅用于幂等请求恢复原二维码。 + */ + @Schema(description = "访问令牌密文") + private String accessTokenCiphertext; + + /** + * 支付业务类型编码,首版固定为云消费机支付业务。 + */ + @Schema(description = "支付业务类型编码") + private String bizType; + + /** + * 调用方业务订单唯一标识,同一租户内用于支付幂等。 + */ + @Schema(description = "支付业务id") + private String bizId; + + /** + * 付款用户ID;匿名消费机订单保存为 {@code -1L}。 + */ + @Schema(description = "付款用户ID") + private Long userId; + + /** + * 收款商户ID,对应钱包服务中的商户主键。 + */ + @Schema(description = "收款商户ID") + private Long mchId; + + /** + * 应付金额,单位为人民币分,和绑定的第三方子订单金额保持一致。 + */ + @Schema(description = "支付金额,单位为分") + private Integer price; + + /** + * 聚合会话状态编码,对应 {@code ThirdAggregatePaySessionStatusEnum}。 + */ + @Schema(description = "聚合会话状态编码") + private String status; + + /** + * 首个有效 OAuth 回调原子选定的支付平台编码;尚未扫码时为空。 + */ + @Schema(description = "选定支付平台编码") + private String selectedPlatform; + + /** + * 绑定的 {@code third_pay_order.pay_no};未创建第三方子订单时为空。 + */ + @Schema(description = "第三方支付子订单号") + private String payNo; + + /** + * 支付宝用户ID或微信服务商公众号 sp_openid 的 SHA-256 摘要,用于阻止不同付款人并发复用会话。 + */ + @Schema(description = "付款人身份SHA-256摘要") + private String payerIdentityHash; + + /** + * 聚合二维码和第三方子订单的服务端失效时间,使用服务端本地时区。 + */ + @Schema(description = "失效时间") + private LocalDateTime expireTime; + + /** + * 聚合会话进入成功、关闭、过期或创建失败终态的服务端时间。 + */ + @Schema(description = "终态确认时间") + private LocalDateTime resolvedTime; + + /** + * 创建人,由公共字段填充器写入;匿名扫码场景允许为空。 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建人") + private String createBy; + + /** + * 会话创建时间,由公共字段填充器写入。 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + /** + * 最近修改人,由公共字段填充器写入;定时任务更新时允许为空。 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改人") + private String updateBy; + + /** + * 最近状态或绑定信息更新时间,由公共字段填充器写入。 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改时间") + private LocalDateTime updateTime; + + /** + * 逻辑删除标记,{@code 0} 表示有效,{@code 1} 表示已删除。 + */ + @TableLogic + @TableField(fill = FieldFill.INSERT) + @Schema(description = "逻辑删除标记") + private String delFlag; + + /** + * 会话所属租户ID,由多租户拦截器和公共字段填充逻辑维护。 + */ + @Schema(description = "租户ID") + private Long tenantId; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateOrderCodePayStatusVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateOrderCodePayStatusVo.java new file mode 100644 index 000000000..3b4ede75a --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateOrderCodePayStatusVo.java @@ -0,0 +1,53 @@ +package com.bsd.pjcloud.thirdparty.vo.appplus.pay; + +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 聚合订单码最小支付状态。 + * + *

不返回访问令牌、付款人标识、商户配置或第三方原始报文。

+ */ +@Data +@Schema(description = "聚合订单码最小支付状态") +public class ThirdAggregateOrderCodePayStatusVo { + + /** + * 聚合支付服务生成的会话编号。 + */ + @Schema(description = "聚合支付会话编号") + private String aggregateNo; + + /** + * 聚合会话内部状态编码,对应 {@code ThirdAggregatePaySessionStatusEnum}。 + */ + @Schema(description = "聚合会话状态编码") + private String status; + + /** + * 首个有效扫码后选定的支付平台;尚未扫码时为空。 + */ + @Schema(description = "选定支付平台") + private AppPlusPlatformTypeEnum selectedPlatform; + + /** + * 实际第三方 JSAPI 子订单号;尚未选定平台时为空。 + */ + @Schema(description = "第三方支付子订单号") + private String payNo; + + /** + * 是否已经由第三方支付平台确认支付成功。 + */ + @Schema(description = "是否支付成功") + private Boolean paySuccess; + + /** + * 服务端认定的聚合二维码失效时间,使用服务端本地时区。 + */ + @Schema(description = "聚合二维码失效时间") + private LocalDateTime expireTime; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateOrderCodePayVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateOrderCodePayVo.java new file mode 100644 index 000000000..4da1527b9 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdAggregateOrderCodePayVo.java @@ -0,0 +1,38 @@ +package com.bsd.pjcloud.thirdparty.vo.appplus.pay; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 支付宝与微信聚合订单码创建结果。 + */ +@Data +@Schema(description = "支付宝与微信聚合订单码创建结果") +public class ThirdAggregateOrderCodePayVo { + + /** + * 聚合支付服务生成的会话编号,用于内部状态查询和问题定位。 + */ + @Schema(description = "聚合支付会话编号") + private String aggregateNo; + + /** + * 同时供支付宝和微信扫码访问的 HTTPS 收银地址,不是图片或 Base64。 + */ + @Schema(description = "聚合二维码内容串") + private String qrCode; + + /** + * 二维码协议类型,固定为 {@code AGGREGATE_ORDER_PAY},不得与自有钱包码混用。 + */ + @Schema(description = "二维码协议类型") + private String qrCodeType; + + /** + * 服务端认定的聚合二维码失效时间,使用服务端本地时区。 + */ + @Schema(description = "聚合二维码失效时间") + private LocalDateTime expireTime; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdOrderCodePayStatusVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdOrderCodePayStatusVo.java new file mode 100644 index 000000000..6b8363fff --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdOrderCodePayStatusVo.java @@ -0,0 +1,40 @@ +package com.bsd.pjcloud.thirdparty.vo.appplus.pay; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 订单码支付状态查询结果。 + * + *

仅返回调用方确认付款所需的最小状态,不包含商户配置和第三方平台原始报文。

+ */ +@Data +@Schema(description = "订单码支付状态查询结果") +public class ThirdOrderCodePayStatusVo { + + /** + * 第三方支付服务生成的唯一商户订单号。 + */ + @Schema(description = "支付订单号") + private String payNo; + + /** + * third_pay_order 中保存的内部支付状态编码。 + */ + @Schema(description = "内部支付状态编码") + private String status; + + /** + * 是否已经确认支付成功,仅内部状态为支付成功时返回 {@code true}。 + */ + @Schema(description = "是否支付成功") + private Boolean paySuccess; + + /** + * 服务端认定的二维码失效时间,使用服务端本地时区。 + */ + @Schema(description = "二维码失效时间") + private LocalDateTime expireTime; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdOrderCodePayVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdOrderCodePayVo.java new file mode 100644 index 000000000..c1df07470 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/ThirdOrderCodePayVo.java @@ -0,0 +1,34 @@ +package com.bsd.pjcloud.thirdparty.vo.appplus.pay; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 订单码支付创建结果。 + * + *

二维码字段是支付宝 {@code qr_code} 或微信 {@code code_url} 内容串,调用方负责将其渲染为二维码图片。

+ */ +@Data +@Schema(description = "订单码支付创建结果") +public class ThirdOrderCodePayVo { + + /** + * 第三方支付服务生成的唯一商户订单号,用于后续查询支付状态。 + */ + @Schema(description = "支付订单号") + private String payNo; + + /** + * 支付宝 {@code qr_code} 或微信 {@code code_url} 内容串,不是图片、Base64 或二进制数据。 + */ + @Schema(description = "第三方支付平台二维码内容串") + private String qrCode; + + /** + * 服务端认定的二维码失效时间,使用服务端本地时区。 + */ + @Schema(description = "二维码失效时间") + private LocalDateTime expireTime; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AggregateWechatPayConfiguration.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AggregateWechatPayConfiguration.java new file mode 100644 index 000000000..b8fc7d321 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AggregateWechatPayConfiguration.java @@ -0,0 +1,40 @@ +package com.bsd.pjcloud.thirdparty.config; + +import com.wechat.pay.java.core.Config; +import com.wechat.pay.java.core.RSAAutoCertificateConfig; +import com.wechat.pay.java.service.partnerpayments.jsapi.JsapiServiceExtension; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 微信服务商 JSAPI 聚合支付 SDK 配置。 + * + *

单独提供 Bean,避免改变现有 App、Native、退款和分账 SDK Bean 的创建方式。

+ */ +@Configuration +@RequiredArgsConstructor +public class AggregateWechatPayConfiguration { + + /** + * 现有微信服务商证书和 APIv3 配置来源。 + */ + private final AppPlusPayConfig appPlusPayConfig; + + /** + * 构建微信支付服务商 JSAPI 扩展服务,供聚合码创建、查单和关单复用。 + * + * @return 使用现有服务商证书初始化的 JSAPI 支付服务 + */ + @Bean + public JsapiServiceExtension aggregateWechatJsapiServiceExtension() { + AppPlusPayConfig.WechatConfig wechatConfig = appPlusPayConfig.getWechatConfig(); + Config config = new RSAAutoCertificateConfig.Builder() + .merchantId(wechatConfig.getMerchantId()) + .privateKeyFromPath(wechatConfig.getMchPrivatePath()) + .merchantSerialNumber(wechatConfig.getMerchantSerialNumber()) + .apiV3Key(wechatConfig.getApiV3Key()) + .build(); + return new JsapiServiceExtension.Builder().config(config).build(); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/initconfig/WechatV3Configuration.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/initconfig/WechatV3Configuration.java index f211de95b..95d56d492 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/initconfig/WechatV3Configuration.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/initconfig/WechatV3Configuration.java @@ -10,6 +10,7 @@ import com.wechat.pay.java.core.RSAAutoCertificateConfig; import com.wechat.pay.java.core.notification.NotificationConfig; import com.wechat.pay.java.core.notification.NotificationParser; import com.wechat.pay.java.service.partnerpayments.app.AppServiceExtension; +import com.wechat.pay.java.service.partnerpayments.nativepay.NativePayService; import com.wechat.pay.java.service.profitsharing.ProfitsharingService; import com.wechat.pay.java.service.refund.RefundService; import lombok.extern.slf4j.Slf4j; @@ -55,6 +56,23 @@ public class WechatV3Configuration { return new AppServiceExtension.Builder().config(config).build(); } + /** + * 构建微信支付服务商 Native SDK 服务,供订单码下单、查单和关单复用。 + * + * @return 使用现有服务商证书、商户号和 APIv3 密钥初始化的 Native 支付服务 + */ + @Bean + public NativePayService nativePayService() { + AppPlusPayConfig.WechatConfig wechatConfig = properties.getWechatConfig(); + Config config = new RSAAutoCertificateConfig.Builder() + .merchantId(wechatConfig.getMerchantId()) + .privateKeyFromPath(wechatConfig.getMchPrivatePath()) + .merchantSerialNumber(wechatConfig.getMerchantSerialNumber()) + .apiV3Key(wechatConfig.getApiV3Key()) + .build(); + return new NativePayService.Builder().config(config).build(); + } + @Bean public NotificationParser notificationParser() { AppPlusPayConfig.WechatConfig wechatConfig = properties.getWechatConfig(); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayController.java new file mode 100644 index 000000000..217004474 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayController.java @@ -0,0 +1,172 @@ +package com.bsd.pjcloud.thirdparty.controller.appplus; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.security.annotation.Inner; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierPageData; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateCashierHtmlRenderer; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URI; + +/** + * 支付宝与微信聚合订单码匿名收银入口。 + * + *

所有入口先用租户唯一编码恢复租户上下文,不接收租户ID或任意回跳地址。

+ */ +@RestController +@RequiredArgsConstructor +@Validated +@RequestMapping("/aggregate-order-pay") +public class ThirdAggregateOrderPayController { + + /** + * 聚合支付编排服务,负责访问令牌、OAuth state、付款人互斥和状态查询。 + */ + private final ThirdAggregateOrderPayService aggregateOrderPayService; + + /** + * 固定收银 HTML 渲染器,负责 CSP nonce 和 JSBridge 页面输出。 + */ + private final ThirdAggregateCashierHtmlRenderer htmlRenderer; + + /** + * 识别支付宝或微信扫码客户端并跳转对应 OAuth 授权入口。 + * + * @param tenantCode 聚合二维码中的租户唯一编码 + * @param token 聚合二维码中的随机访问令牌 + * @param userAgent 扫码客户端 User-Agent + * @return OAuth 重定向;普通浏览器或失效请求返回固定提示页 + */ + @GetMapping(value = "/cashier/{tenantCode}/{token}", produces = MediaType.TEXT_HTML_VALUE) + @Inner(value = false) + public ResponseEntity cashier(@NotBlank @PathVariable String tenantCode, @NotBlank @PathVariable String token, + @RequestHeader(value = HttpHeaders.USER_AGENT, required = false) String userAgent) { + try { + String oauthUrl = aggregateOrderPayService.getCashierOauthUrl(tenantCode, token, userAgent); + return ResponseEntity.status(HttpStatus.FOUND).headers(baseHeaders(null)).location(URI.create(oauthUrl)).build(); + } catch (RuntimeException exception) { + return messageResponse(exception.getMessage(), HttpStatus.BAD_REQUEST); + } + } + + /** + * 接收服务商统一公众号 OAuth 回调并返回微信 JSBridge 收银页。 + * + * @param tenantCode 聚合 URL 中的租户唯一编码 + * @param code 微信一次性授权码 + * @param state 服务端创建的一次性 OAuth state + * @return 带 CSP nonce 的固定微信收银页 + */ + @GetMapping(value = "/oauth/wechat/{tenantCode}", produces = MediaType.TEXT_HTML_VALUE) + @Inner(value = false) + public ResponseEntity wechatOauth(@NotBlank @PathVariable String tenantCode, + @NotBlank @RequestParam("code") String code, + @NotBlank @RequestParam("state") String state) { + try { + ThirdAggregateCashierPageData pageData = aggregateOrderPayService.handleWechatOauth(tenantCode, code, state); + return payPageResponse(pageData); + } catch (RuntimeException exception) { + return messageResponse(exception.getMessage(), HttpStatus.BAD_REQUEST); + } + } + + /** + * 接收支付宝 OAuth 回调并返回 AlipayJSBridge 收银页。 + * + * @param tenantCode 聚合 URL 中的租户唯一编码 + * @param authCode 支付宝一次性授权码 + * @param state 服务端创建的一次性 OAuth state + * @return 带 CSP nonce 的固定支付宝收银页 + */ + @GetMapping(value = "/oauth/alipay/{tenantCode}", produces = MediaType.TEXT_HTML_VALUE) + @Inner(value = false) + public ResponseEntity alipayOauth(@NotBlank @PathVariable String tenantCode, + @NotBlank @RequestParam("auth_code") String authCode, + @NotBlank @RequestParam("state") String state) { + try { + ThirdAggregateCashierPageData pageData = aggregateOrderPayService.handleAlipayOauth(tenantCode, authCode, state); + return payPageResponse(pageData); + } catch (RuntimeException exception) { + return messageResponse(exception.getMessage(), HttpStatus.BAD_REQUEST); + } + } + + /** + * 使用短期 ticket 返回收银页轮询所需的最小支付状态。 + * + * @param tenantCode 聚合 URL 中的租户唯一编码 + * @param ticket 收银页短期状态票据 + * @return 聚合状态、选定平台和支付成功标识 + */ + @GetMapping("/status/{tenantCode}/{ticket}") + @Inner(value = false) + public ResponseEntity> status(@NotBlank @PathVariable String tenantCode, + @NotBlank @PathVariable String ticket) { + R body; + try { + body = R.ok(aggregateOrderPayService.getStatusByTicket(tenantCode, ticket)); + } catch (RuntimeException exception) { + body = R.failed(exception.getMessage()); + } + return ResponseEntity.ok().headers(baseHeaders(null)).body(body); + } + + /** + * 构造支付页响应并设置严格 CSP、禁止缓存和禁止来源泄漏响应头。 + * + * @param pageData 平台调起参数和短期状态地址 + * @return HTML 收银页响应 + */ + private ResponseEntity payPageResponse(ThirdAggregateCashierPageData pageData) { + String nonce = htmlRenderer.createNonce(); + return ResponseEntity.ok().headers(baseHeaders(nonce)).contentType(MediaType.TEXT_HTML) + .body(htmlRenderer.renderPayPage(pageData, nonce)); + } + + /** + * 构造安全提示页响应,提示文本由渲染器执行 HTML 转义。 + * + * @param message 面向付款人的短提示 + * @param status HTTP 状态码 + * @return 不包含平台参数的固定 HTML 响应 + */ + private ResponseEntity messageResponse(String message, HttpStatus status) { + String nonce = htmlRenderer.createNonce(); + return ResponseEntity.status(status).headers(baseHeaders(nonce)).contentType(MediaType.TEXT_HTML) + .body(htmlRenderer.renderMessagePage(message, nonce)); + } + + /** + * 构造聚合收银入口共用的安全响应头。 + * + * @param nonce HTML 响应 CSP nonce;重定向和 JSON 响应时为空 + * @return 禁止缓存、禁止来源泄漏和禁止 MIME 猜测的响应头 + */ + private HttpHeaders baseHeaders(String nonce) { + HttpHeaders headers = new HttpHeaders(); + headers.setCacheControl(CacheControl.noStore()); + headers.set("Pragma", "no-cache"); + headers.set("Referrer-Policy", "no-referrer"); + headers.set("X-Content-Type-Options", "nosniff"); + if (nonce != null) { + headers.set("Content-Security-Policy", "default-src 'none'; script-src 'nonce-" + nonce + + "'; style-src 'nonce-" + nonce + "'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"); + } + return headers; + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAppPlusController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAppPlusController.java index b2781ea95..b119c7d99 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAppPlusController.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAppPlusController.java @@ -7,10 +7,16 @@ import com.bsd.pjcloud.common.security.annotation.Inner; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMicropayDto; import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMiniRemotePayDto; +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto; +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdOrderCodePayDto; import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdRemoterRefundDto; import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusService; import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatFzService; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdMiniAppPayVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayStatusVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; @@ -18,26 +24,45 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List; +/** + * 第三方支付内部远程调用入口,统一通过 {@link Inner} 约束服务间访问。 + */ @Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/pay-remoter") @Tag(description = "remoter", name = "支付") @SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +@Validated public class ThirdAppPlusController { + /** + * 微信分账服务,用于现有商户配置变更后的接收方同步。 + */ private final ThirdAppPlusWechatFzService thirdAppPlusWechatFzService; + + /** + * 第三方支付编排服务,承接退款、付款码、订单码和 App 支付请求。 + */ private final ThirdAppPlusService thirdAppPlusService; /* ************************************* start 远程调用 start ************************************* */ + /** + * 接收内部支付退款请求。 + * + * @param dto 退款业务、订单和金额信息 + * @return 退款平台订单号 + */ @Operation(summary = "支付退款") @PostMapping("/refund") @Inner @@ -45,6 +70,12 @@ public class ThirdAppPlusController { return R.ok(thirdAppPlusService.remoterRefund(dto)); } + /** + * 接收内部付款码支付请求。 + * + * @param dto 用户付款码、业务订单、商户和金额信息 + * @return 支付成功后的内部支付订单号 + */ @Operation(summary = "付款码支付") @PostMapping("/micropay") @Inner @@ -52,6 +83,65 @@ public class ThirdAppPlusController { return R.ok(thirdAppPlusService.micropay(dto)); } + /** + * 接收内部服务的支付宝或微信订单码创建请求。 + * + * @param dto 经过 Bean Validation 校验的订单码支付参数 + * @return 支付订单号、二维码内容串和过期时间 + */ + @Operation(summary = "订单码支付") + @PostMapping("/order-code-pay") + @Inner + public R orderCodePay(@Valid @RequestBody ThirdOrderCodePayDto dto) { + return R.ok(thirdAppPlusService.orderCodePay(dto)); + } + + /** + * 查询内部订单码支付状态。 + * + * @param payNo 第三方支付服务生成的支付订单号 + * @return 支付订单的最小状态信息 + */ + @Operation(summary = "查询订单码支付状态") + @GetMapping("/order-code-pay/status") + @Inner + public R getOrderCodePayStatus(@NotBlank(message = "支付订单号不能为空") @RequestParam("payNo") String payNo) { + return R.ok(thirdAppPlusService.getOrderCodePayStatus(payNo)); + } + + /** + * 接收内部服务的支付宝与微信聚合订单码创建请求。 + * + * @param dto 不指定支付平台的业务订单、商户和金额参数 + * @return 聚合会话编号、HTTPS 二维码内容和过期时间 + */ + @Operation(summary = "支付宝微信聚合订单码支付") + @PostMapping("/aggregate-order-code-pay") + @Inner + public R aggregateOrderCodePay(@Valid @RequestBody ThirdAggregateOrderCodePayDto dto) { + return R.ok(thirdAppPlusService.aggregateOrderCodePay(dto)); + } + + /** + * 查询内部聚合订单码支付状态。 + * + * @param aggregateNo 聚合支付会话编号 + * @return 聚合状态、选定平台和第三方子订单号 + */ + @Operation(summary = "查询支付宝微信聚合订单码支付状态") + @GetMapping("/aggregate-order-code-pay/status") + @Inner + public R getAggregateOrderCodePayStatus( + @NotBlank(message = "聚合支付会话编号不能为空") @RequestParam("aggregateNo") String aggregateNo) { + return R.ok(thirdAppPlusService.getAggregateOrderCodePayStatus(aggregateNo)); + } + + /** + * 接收现有 App 支付请求。 + * + * @param dto App 用户及支付业务参数 + * @return App 拉起第三方支付所需参数 + */ @Operation(summary = "app支付") @PostMapping("/app-pay") public R remoterAppPay(@RequestBody ThirdMiniRemotePayDto dto) { @@ -60,6 +150,12 @@ public class ThirdAppPlusController { /* ************************************* end 远程调用 end ************************************* */ + /** + * 同步商户配置变更到微信分账接收方维护流程。 + * + * @param dto 变更后的商户详情和支付配置 + * @return 接收变更请求后返回 {@code true} + */ @Operation(summary = "商户改动") @PostMapping("/change-mch") @Inner diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdMinAppPayCotroller.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdMinAppPayCotroller.java index 097d42b87..0afdd0e12 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdMinAppPayCotroller.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdMinAppPayCotroller.java @@ -18,12 +18,16 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.HashMap; import java.util.Map; +/** + * App 与小程序支付接口及第三方平台回调入口。 + */ @RestController @RequiredArgsConstructor @RequestMapping("/third-app-pay") @@ -31,6 +35,9 @@ import java.util.Map; @SecurityRequirement(name = HttpHeaders.AUTHORIZATION) public class ThirdMinAppPayCotroller { + /** + * 第三方支付编排服务,负责支付、退款、查询和回调状态推进。 + */ private final ThirdAppPlusService thirdMiniAppService; @@ -58,17 +65,25 @@ public class ThirdMinAppPayCotroller { return R.ok(thirdMiniAppService.miniPay(user.getId(), dto)); } + /** + * 接收支付宝或微信共享支付回调,并按平台协议返回纯文本确认值。 + * + * @param request Servlet 请求,用于读取支付宝表单参数 + * @param httpEntity 请求实体,用于读取微信回调原始请求体 + * @param headers 请求头,用于微信回调验签 + * @return 支付平台要求的纯文本 {@code success} 或 {@code fail} + */ @Operation(summary = "支付回调") - @PostMapping("/app/pay-callback") + @PostMapping(value = "/app/pay-callback", produces = MediaType.TEXT_PLAIN_VALUE) @Inner(value = false) - public R miniPayCallback(HttpServletRequest request, HttpEntity httpEntity, @RequestHeader Map headers) { + public String miniPayCallback(HttpServletRequest request, HttpEntity httpEntity, @RequestHeader Map headers) { String body = httpEntity.getBody(); Map parameterMap = request.getParameterMap(); Map params = new HashMap<>(); for (Map.Entry entry : parameterMap.entrySet()) { params.put(entry.getKey(), entry.getValue()[0]); } - return R.ok(thirdMiniAppService.miniPayCallback(params, body, headers)); + return thirdMiniAppService.miniPayCallback(params, body, headers); } @Operation(summary = "退款回调") diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJob.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJob.java new file mode 100644 index 000000000..b27ed93b5 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJob.java @@ -0,0 +1,71 @@ +package com.bsd.pjcloud.thirdparty.job; + +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 支付宝与微信聚合订单码状态轮询和到期关闭任务。 + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class ThirdAggregateOrderPayJob { + + /** + * 单轮最多处理的聚合会话数量,限制第三方网关慢调用对调度线程的占用。 + */ + private static final int BATCH_SIZE = 100; + + /** + * 聚合会话服务,用于跨租户分批读取仍可能支付的会话。 + */ + private final ThirdAggregatePaySessionService aggregatePaySessionService; + + /** + * 聚合支付编排服务,负责平台查单、撤销、关单和原子状态同步。 + */ + private final ThirdAggregateOrderPayService aggregateOrderPayService; + + /** + * 每五秒分批处理等待扫码、平台创建中和支付中的聚合会话。 + * + *

调度线程初始没有租户上下文,单条处理前使用记录自身 tenantId 恢复上下文; + * Redis 键仍由公共基础设施自动附加租户前缀。

+ */ + @Scheduled(fixedDelay = 5000L, initialDelay = 5000L) + public void handlePendingSessions() { + List sessions = aggregatePaySessionService.list( + new LambdaQueryWrapper() + .in(ThirdAggregatePaySessionEntity::getStatus, + ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(), + ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(), + ThirdAggregatePaySessionStatusEnum.PAYING.getType()) + .orderByAsc(ThirdAggregatePaySessionEntity::getCreateTime) + .last("limit " + BATCH_SIZE)); + if (ObjectUtil.isEmpty(sessions)) { + return; + } + for (ThirdAggregatePaySessionEntity session : sessions) { + if (session.getTenantId() == null) { + log.warn("聚合支付会话缺少持久化租户ID,sessionId:{}", session.getId()); + continue; + } + try { + TenantBroker.runAs(session.getTenantId(), tenantId -> aggregateOrderPayService.processPendingSession(session.getId())); + } catch (Exception exception) { + log.error("处理聚合支付会话异常,sessionId:{},tenantId:{}", session.getId(), session.getTenantId(), exception); + } + } + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJob.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJob.java index 60d7435f8..491d5c74b 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJob.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJob.java @@ -4,23 +4,42 @@ package com.bsd.pjcloud.thirdparty.job; import cn.hutool.core.util.ObjectUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusService; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.util.List; +/** + * 第三方支付订单定时处理任务,负责补偿业务回调及轮询支付宝、微信订单码状态。 + */ @Component @Slf4j @RequiredArgsConstructor public class ThirdAppPlusPayJob { + /** + * 单轮订单码轮询的最大处理数量,避免慢支付网关调用长期占用调度线程。 + */ + private static final int ORDER_CODE_BATCH_SIZE = 100; + + /** + * 支付订单服务,用于分批读取仍未支付的订单码记录。 + */ private final ThirdPayOrderService thirdPayOrderService; + + /** + * 支付编排服务,负责按平台查询并执行带状态条件的成功或关闭更新。 + */ private final ThirdAppPlusService thirdAppPlusService; /** @@ -47,7 +66,42 @@ public class ThirdAppPlusPayJob { } /** - * 处理,长时间未支付的订单(1个小时不支付) + * 每五秒分批处理支付宝和微信云消费机订单码。 + * + *

初始查询在线程无租户上下文时跨租户读取,处理单条订单前通过 TenantBroker + * 恢复订单所属租户,确保后续读取和原子更新都受正确租户条件约束。

*/ + @Scheduled(fixedDelay = 5000L, initialDelay = 5000L) + public void handleOrderCodeOrders() { + List orders = thirdPayOrderService.list(new LambdaQueryWrapper() + .in(ThirdPayOrderEntity::getPayPlatformType, + AppPlusPlatformTypeEnum.ALI_PAY.getType(), AppPlusPlatformTypeEnum.WECHAT.getType()) + .eq(ThirdPayOrderEntity::getBizType, ThirdPayOrderBizTypeEnum.YXFJ_PAY.getType()) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType()) + .isNotNull(ThirdPayOrderEntity::getExpireTime) + .notExists("SELECT 1 FROM third_aggregate_pay_session s WHERE s.pay_no = third_pay_order.pay_no AND s.del_flag = '0'") + .orderByAsc(ThirdPayOrderEntity::getCreateTime) + .last("limit " + ORDER_CODE_BATCH_SIZE)); + if (ObjectUtil.isEmpty(orders)) { + return; + } + for (ThirdPayOrderEntity order : orders) { + if (order.getTenantId() == null) { + log.warn("订单码缺少租户ID,orderId:{},platform:{}", order.getId(), order.getPayPlatformType()); + continue; + } + try { + TenantBroker.runAs(order.getTenantId(), tenantId -> { + ThirdPayOrderEntity latestOrder = thirdPayOrderService.getById(order.getId()); + if (latestOrder != null && ObjectUtil.equals(latestOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) { + thirdAppPlusService.queryPayResult(latestOrder); + } + }); + } catch (Exception exception) { + log.error("处理订单码状态异常,orderId:{},tenantId:{},platform:{}", + order.getId(), order.getTenantId(), order.getPayPlatformType(), exception); + } + } + } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdAggregatePaySessionMapper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdAggregatePaySessionMapper.java new file mode 100644 index 000000000..63af5986f --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdAggregatePaySessionMapper.java @@ -0,0 +1,14 @@ +package com.bsd.pjcloud.thirdparty.mapper.pay; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 聚合订单码支付会话 Mapper。 + * + *

所有读写均通过多租户 MyBatis 拦截器自动附加租户条件。

+ */ +@Mapper +public interface ThirdAggregatePaySessionMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateAlipayOauthIdentity.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateAlipayOauthIdentity.java new file mode 100644 index 000000000..043dbcf26 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateAlipayOauthIdentity.java @@ -0,0 +1,59 @@ +package com.bsd.pjcloud.thirdparty.model.pay; + +import cn.hutool.core.util.StrUtil; + +/** + * 支付宝用户授权换取的付款人身份。 + * + *

新应用优先使用 open_id,兼容尚未返回 open_id 的存量应用 user_id。

+ */ +public class ThirdAggregateAlipayOauthIdentity { + + /** + * 支付宝开放平台用户 open_id;接口未返回时为空。 + */ + private final String openId; + + /** + * 支付宝历史用户 user_id;新接口只返回 open_id 时可为空。 + */ + private final String userId; + + /** + * 创建支付宝付款人身份值对象。 + * + * @param openId 支付宝 open_id,允许为空 + * @param userId 支付宝 user_id,允许为空 + */ + public ThirdAggregateAlipayOauthIdentity(String openId, String userId) { + this.openId = openId; + this.userId = userId; + } + + /** + * 获取支付宝 open_id。 + * + * @return open_id;未返回时为空 + */ + public String getOpenId() { + return openId; + } + + /** + * 获取支付宝历史 user_id。 + * + * @return user_id;未返回时为空 + */ + public String getUserId() { + return userId; + } + + /** + * 获取用于会话付款人互斥的稳定身份值,优先选择 open_id。 + * + * @return 非空支付宝付款人身份值 + */ + public String getStableIdentity() { + return StrUtil.firstNonBlank(openId, userId); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateCashierLaunch.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateCashierLaunch.java new file mode 100644 index 000000000..cdd9d17ab --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateCashierLaunch.java @@ -0,0 +1,68 @@ +package com.bsd.pjcloud.thirdparty.model.pay; + +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 聚合收银页调起第三方 JSBridge 所需的最小参数。 + */ +public class ThirdAggregateCashierLaunch { + + /** + * 当前会话已经原子选定的支付宝或微信平台。 + */ + private final AppPlusPlatformTypeEnum platform; + + /** + * 已保存到 {@code third_pay_order} 的第三方商户订单号。 + */ + private final String payNo; + + /** + * 传给平台 JSBridge 的签名参数副本,不包含商户密钥或公众号 Secret。 + */ + private final Map payParams; + + /** + * 创建收银页调起结果并复制参数,避免调用方修改已持久化语义。 + * + * @param platform 支付平台 + * @param payNo 第三方商户订单号 + * @param payParams JSBridge 调起参数 + */ + public ThirdAggregateCashierLaunch(AppPlusPlatformTypeEnum platform, String payNo, Map payParams) { + this.platform = platform; + this.payNo = payNo; + this.payParams = Collections.unmodifiableMap(new LinkedHashMap<>(payParams)); + } + + /** + * 获取当前选定支付平台。 + * + * @return 支付宝或微信平台 + */ + public AppPlusPlatformTypeEnum getPlatform() { + return platform; + } + + /** + * 获取第三方商户订单号。 + * + * @return {@code third_pay_order.pay_no} + */ + public String getPayNo() { + return payNo; + } + + /** + * 获取不可变的 JSBridge 调起参数。 + * + * @return 不包含密钥的调起参数 + */ + public Map getPayParams() { + return payParams; + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateCashierPageData.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateCashierPageData.java new file mode 100644 index 000000000..23309dfea --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregateCashierPageData.java @@ -0,0 +1,46 @@ +package com.bsd.pjcloud.thirdparty.model.pay; + +/** + * 聚合 OAuth 回调后渲染收银页所需的数据。 + */ +public class ThirdAggregateCashierPageData { + + /** + * 支付宝或微信 JSBridge 调起参数及平台信息。 + */ + private final ThirdAggregateCashierLaunch launch; + + /** + * 当前租户下使用短期 ticket 查询支付结果的 HTTPS 地址。 + */ + private final String statusUrl; + + /** + * 创建聚合收银页数据。 + * + * @param launch JSBridge 调起数据 + * @param statusUrl 短期状态查询地址 + */ + public ThirdAggregateCashierPageData(ThirdAggregateCashierLaunch launch, String statusUrl) { + this.launch = launch; + this.statusUrl = statusUrl; + } + + /** + * 获取 JSBridge 调起数据。 + * + * @return 当前选定平台的调起数据 + */ + public ThirdAggregateCashierLaunch getLaunch() { + return launch; + } + + /** + * 获取短期状态查询地址。 + * + * @return 当前租户下的状态查询 HTTPS 地址 + */ + public String getStatusUrl() { + return statusUrl; + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregatePayCreateContext.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregatePayCreateContext.java new file mode 100644 index 000000000..7bea48cfc --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/model/pay/ThirdAggregatePayCreateContext.java @@ -0,0 +1,50 @@ +package com.bsd.pjcloud.thirdparty.model.pay; + +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 聚合支付平台子订单创建上下文。 + * + *

由聚合编排服务在锁定平台后构造,仅在第三方服务内部传递。

+ */ +@Data +public class ThirdAggregatePayCreateContext { + + /** + * 预分配的第三方商户订单号,必须与聚合会话绑定值一致。 + */ + private String payNo; + + /** + * 调用方业务订单唯一标识。 + */ + private String bizId; + + /** + * 支付业务类型,首版固定为云消费机支付。 + */ + private ThirdPayOrderBizTypeEnum bizType; + + /** + * 付款用户ID;匿名消费机订单为 {@code -1L}。 + */ + private Long userId; + + /** + * 收款商户ID。 + */ + private Long mchId; + + /** + * 应付金额,单位为人民币分。 + */ + private Integer amount; + + /** + * 服务端支付截止时间,同时写入聚合会话和第三方子订单。 + */ + private LocalDateTime expireTime; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateAliPayService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateAliPayService.java new file mode 100644 index 000000000..6da11f473 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateAliPayService.java @@ -0,0 +1,51 @@ +package com.bsd.pjcloud.thirdparty.service; + +import com.alipay.api.response.AlipayTradeCancelResponse; +import com.alipay.api.response.AlipayTradeQueryResponse; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateAlipayOauthIdentity; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext; +import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; + +/** + * 支付宝聚合订单码 OAuth 与 JSAPI 支付能力。 + */ +public interface ThirdAggregateAliPayService { + + /** + * 使用一次性授权码换取支付宝付款人身份。 + * + * @param authCode 支付宝 OAuth 回调授权码 + * @return 包含 open_id 或兼容 user_id 的付款人身份 + */ + ThirdAggregateAlipayOauthIdentity exchangeOauthCode(String authCode); + + /** + * 调用 {@code alipay.trade.create} 创建服务商模式 JSAPI 子订单并保存内部支付订单。 + * + * @param context 已绑定聚合会话的支付上下文 + * @param paySetting 当前商户启用的支付宝授权配置 + * @param identity 已通过 OAuth 获取的支付宝付款人身份 + * @return 支付宝 JSBridge 调起参数 + */ + ThirdAggregateCashierLaunch createPay(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting, + ThirdAggregateAlipayOauthIdentity identity); + + /** + * 查询聚合支付宝子订单状态。 + * + * @param payNo 第三方商户订单号 + * @param settingJson 创建订单时保存的商户配置快照 + * @return 支付宝查询响应;网络或 SDK 异常时返回 {@code null} + */ + AlipayTradeQueryResponse query(String payNo, String settingJson); + + /** + * 撤销到期但尚未完成的聚合支付宝子订单。 + * + * @param payNo 第三方商户订单号 + * @param settingJson 创建订单时保存的商户配置快照 + * @return 支付宝撤销响应;网络或 SDK 异常时返回 {@code null} + */ + AlipayTradeCancelResponse cancel(String payNo, String settingJson); +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateCashierHtmlRenderer.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateCashierHtmlRenderer.java new file mode 100644 index 000000000..af896c09d --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateCashierHtmlRenderer.java @@ -0,0 +1,176 @@ +package com.bsd.pjcloud.thirdparty.service; + +import cn.hutool.json.JSONUtil; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierPageData; +import org.springframework.stereotype.Component; + +import java.security.SecureRandom; + +/** + * 聚合支付固定收银 HTML 渲染器。 + * + *

页面不加载外部脚本或前端框架,只调用支付宝或微信客户端内置 JSBridge。

+ */ +@Component +public class ThirdAggregateCashierHtmlRenderer { + + /** + * CSP nonce 随机字节数,每次响应重新生成。 + */ + private static final int NONCE_BYTES = 18; + + /** + * 支付宝 JSBridge 自动调用后显示人工重试入口的等待时间,单位毫秒。 + * + *

部分支付宝 WebView 会限制无用户手势的支付调起,等待后展示按钮可让付款人通过真实点击再次调用。

+ */ + private static final int ALIPAY_LAUNCH_FALLBACK_MILLIS = 1500; + + /** + * 支付宝 JSBridge 注入等待上限,单位毫秒;超时后页面提示付款人确认扫码客户端并允许重试。 + */ + private static final int ALIPAY_BRIDGE_READY_TIMEOUT_MILLIS = 3000; + + /** + * 进程级强随机数生成器,用于每个收银页响应的 CSP nonce。 + */ + private final SecureRandom secureRandom = new SecureRandom(); + + /** + * 渲染调用支付宝或微信 JSBridge 的固定收银页。 + * + * @param pageData 平台调起参数和短期状态查询地址 + * @param nonce 当前响应 CSP nonce + * @return 完整 UTF-8 HTML 文本 + */ + public String renderPayPage(ThirdAggregateCashierPageData pageData, String nonce) { + String payParams = safeScriptJson(pageData.getLaunch().getPayParams()); + String statusUrl = safeScriptJson(pageData.getStatusUrl()); + boolean wechat = AppPlusPlatformTypeEnum.WECHAT.equals(pageData.getLaunch().getPlatform()); + String invokeScript = wechat + ? "function invokePay(){WeixinJSBridge.invoke('getBrandWCPayRequest',payParams,function(){startPolling();});}" + + "if(typeof WeixinJSBridge==='undefined'){document.addEventListener('WeixinJSBridgeReady',invokePay,false);}else{invokePay();}" + : alipayInvokeScript(); + return "" + + "" + + "订单支付
" + + "

正在调起支付

请在当前应用内完成付款

" + + "
" + + ""; + } + + /** + * 构造支付宝内置浏览器的收银台调起脚本。 + * + *

严格按支付宝文档传递 {@code tradeNO},并处理官方结果码。自动调起被 WebView 限制、桥接尚未注入或同步抛错时, + * 页面提供真实点击重试入口;支付结果仍由服务端状态轮询最终确认,避免仅凭客户端结果认定成功。

+ * + * @return 不包含动态业务数据的固定支付宝 JSBridge 脚本 + */ + private String alipayInvokeScript() { + return "const retryButton=document.getElementById('retryButton');const spinner=document.querySelector('.spinner');" + + "let alipayBridgeInvoked=false;let alipayResultReceived=false;" + + "function showAlipayRetry(title,message){setState(title,message);spinner.hidden=true;retryButton.hidden=false;}" + + "function hideAlipayRetry(){spinner.hidden=false;retryButton.hidden=true;}" + + "function handleAlipayResult(result){alipayResultReceived=true;const resultCode=String(result&&result.resultCode||'');" + + "if(resultCode==='9000'){hideAlipayRetry();setState('支付结果确认中','正在确认服务端支付结果');startPolling();return;}" + + "if(resultCode==='8000'||resultCode==='6004'){hideAlipayRetry();setState('支付结果确认中','请稍候,请勿重复付款');startPolling();return;}" + + "if(resultCode==='6001'){showAlipayRetry('支付已取消','如需继续付款,请重新调起支付宝收银台');startPolling();return;}" + + "if(resultCode==='6002'){showAlipayRetry('网络连接异常','请检查网络后重新调起支付');startPolling();return;}" + + "if(resultCode==='4000'){showAlipayRetry('支付未完成','请重新调起支付或重新扫码');startPolling();return;}" + + "showAlipayRetry('支付未能调起','请重新调起支付宝收银台');startPolling();}" + + "function invokePay(){const tradeNO=payParams&&payParams.tradeNO;" + + "if(typeof tradeNO!=='string'||!tradeNO.trim()){showAlipayRetry('支付参数无效','请关闭页面后重新扫码');return;}" + + "const bridge=window.AlipayJSBridge;if(!bridge||typeof bridge.call!=='function'){" + + "showAlipayRetry('支付组件未就绪','请确认当前页面在支付宝内打开后重试');return;}" + + "alipayBridgeInvoked=true;alipayResultReceived=false;hideAlipayRetry();setState('正在调起支付','请在支付宝内完成付款');" + + "try{bridge.call('tradePay',{tradeNO:tradeNO},handleAlipayResult);" + + "setTimeout(function(){if(!alipayResultReceived){showAlipayRetry('等待打开支付宝收银台'," + + "'未自动打开时,请点击下方按钮继续支付');}}," + ALIPAY_LAUNCH_FALLBACK_MILLIS + ");}" + + "catch(error){alipayBridgeInvoked=false;showAlipayRetry('支付组件调用失败','请重新调起支付或重新扫码');}}" + + "retryButton.addEventListener('click',invokePay);" + + "if(window.AlipayJSBridge&&typeof window.AlipayJSBridge.call==='function'){invokePay();}else{" + + "document.addEventListener('AlipayJSBridgeReady',invokePay,false);" + + "setTimeout(function(){if(!alipayBridgeInvoked){showAlipayRetry('支付组件未就绪'," + + "'请确认当前页面在支付宝内打开后重试');}}," + ALIPAY_BRIDGE_READY_TIMEOUT_MILLIS + ");}"; + } + + /** + * 渲染不包含脚本的固定提示页,供普通浏览器、自有 App、过期和非法请求使用。 + * + * @param message 面向付款人的短提示,服务端会执行 HTML 转义 + * @param nonce 当前响应 CSP nonce + * @return 完整 UTF-8 HTML 文本 + */ + public String renderMessagePage(String message, String nonce) { + return "" + + "" + + "订单支付

无法继续支付

" + escapeHtml(message) + "

"; + } + + /** + * 生成当前响应使用的不可预测 CSP nonce。 + * + * @return URL 安全且不含填充字符的随机 nonce + */ + public String createNonce() { + byte[] bytes = new byte[NONCE_BYTES]; + secureRandom.nextBytes(bytes); + return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + /** + * 返回收银页固定内联样式,不接受任何外部输入。 + * + * @return 适配移动端安全区的 CSS 文本 + */ + private String baseStyles() { + return "*{box-sizing:border-box}body{margin:0;min-height:100vh;background:#f5f7fa;color:#1f2937;" + + "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}main{min-height:100vh;display:flex;" + + "flex-direction:column;align-items:center;justify-content:center;padding:24px;padding-bottom:env(safe-area-inset-bottom)}" + + "h1{margin:18px 0 8px;font-size:22px;font-weight:600;letter-spacing:0}p{margin:0;color:#6b7280;font-size:15px;" + + "line-height:1.6;text-align:center}.spinner{width:36px;height:36px;border:3px solid #d1d5db;border-top-color:#1677ff;" + + "border-radius:50%;animation:spin 1s linear infinite}.spinner[hidden]{display:none}button{margin-top:24px;min-width:152px;" + + "height:44px;padding:0 20px;border:0;border-radius:6px;background:#1677ff;color:#fff;font-size:16px;font-weight:600}" + + "button[hidden]{display:none}@keyframes spin{to{transform:rotate(360deg)}}"; + } + + /** + * 将 Java 对象序列化为可安全嵌入 script 文本的 JSON,阻断闭合标签和 HTML 实体注入。 + * + *

Hutool 对顶层字符串调用 {@code toJsonStr} 不会补 JSON 引号,必须单独使用 {@code quote};否则 HTTPS + * 状态地址会被渲染为非法 JavaScript,并导致包括 JSBridge 调起在内的整段脚本无法执行。

+ * + * @param value 仅包含平台调起参数或服务端固定 URL 的对象 + * @return 已转义小于号、大于号和与号的 JSON 文本 + */ + private String safeScriptJson(Object value) { + String json = value instanceof CharSequence ? JSONUtil.quote(value.toString()) : JSONUtil.toJsonStr(value); + return json.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026"); + } + + /** + * 对提示文本执行最小 HTML 转义,避免业务异常文本进入页面结构。 + * + * @param value 原始提示文本,允许为空 + * @return 可安全放入文本节点的内容 + */ + private String escapeHtml(String value) { + if (value == null) { + return "支付请求无效"; + } + return value.replace("&", "&").replace("<", "<").replace(">", ">") + .replace("\"", """).replace("'", "'"); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java new file mode 100644 index 000000000..7de1944a5 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java @@ -0,0 +1,92 @@ +package com.bsd.pjcloud.thirdparty.service; + +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierPageData; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo; + +/** + * 支付宝与微信聚合订单码支付编排服务。 + */ +public interface ThirdAggregateOrderPayService { + + /** + * 创建或复用同业务的支付宝、微信聚合二维码。 + * + * @param dto 业务订单、商户、金额和可选用户参数 + * @return 聚合会话编号、HTTPS 二维码内容和失效时间 + */ + ThirdAggregateOrderCodePayVo create(ThirdAggregateOrderCodePayDto dto); + + /** + * 按聚合会话编号查询当前租户最小支付状态。 + * + * @param aggregateNo 聚合支付会话编号 + * @return 聚合状态、选定平台和第三方子订单号 + */ + ThirdAggregateOrderCodePayStatusVo getStatus(String aggregateNo); + + /** + * 在旧平台原生订单码创建前检查同业务是否存在阻塞中的聚合会话。 + * + * @param bizType 支付业务类型 + * @param bizId 调用方业务订单唯一标识 + */ + void assertNoBlockingAggregateSession(ThirdPayOrderBizTypeEnum bizType, String bizId); + + /** + * 校验扫码客户端和访问令牌,并创建一次性 OAuth state 后返回平台授权地址。 + * + * @param tenantCode 聚合 URL 中的租户唯一编码 + * @param token 聚合 URL 随机访问令牌 + * @param userAgent 扫码客户端 User-Agent + * @return 支付宝或微信官方 OAuth 授权地址 + */ + String getCashierOauthUrl(String tenantCode, String token, String userAgent); + + /** + * 处理服务商统一公众号 OAuth 回调并准备 JSBridge 收银页。 + * + * @param tenantCode 聚合 URL 中的租户唯一编码 + * @param authCode 微信一次性授权码 + * @param state 服务端创建的一次性 OAuth state + * @return 微信 JSBridge 调起数据和短期状态查询地址 + */ + ThirdAggregateCashierPageData handleWechatOauth(String tenantCode, String authCode, String state); + + /** + * 处理支付宝 OAuth 回调并准备 AlipayJSBridge 收银页。 + * + * @param tenantCode 聚合 URL 中的租户唯一编码 + * @param authCode 支付宝一次性授权码 + * @param state 服务端创建的一次性 OAuth state + * @return 支付宝 JSBridge 调起数据和短期状态查询地址 + */ + ThirdAggregateCashierPageData handleAlipayOauth(String tenantCode, String authCode, String state); + + /** + * 使用短期 ticket 查询当前租户聚合支付状态。 + * + * @param tenantCode 聚合 URL 中的租户唯一编码 + * @param ticket OAuth 回调收银页取得的短期状态票据 + * @return 不含敏感字段的聚合支付状态 + */ + ThirdAggregateOrderCodePayStatusVo getStatusByTicket(String tenantCode, String ticket); + + /** + * 查询、关闭并同步一条待处理聚合会话,供定时任务和创建互斥逻辑复用。 + * + * @param sessionId 聚合支付会话主键 + */ + void processPendingSession(Long sessionId); + + /** + * 判断支付回调订单是否属于聚合会话;属于时同步会话终态并阻止旧业务回调链继续执行。 + * + * @param payOrder 已通过平台验签和关键字段校验的最新第三方支付订单 + * @return 属于聚合 JSAPI 子订单时返回 {@code true} + */ + boolean syncAggregateChildOrder(ThirdPayOrderEntity payOrder); +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregatePaySessionService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregatePaySessionService.java new file mode 100644 index 000000000..6d93aef40 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregatePaySessionService.java @@ -0,0 +1,92 @@ +package com.bsd.pjcloud.thirdparty.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; + +/** + * 聚合订单码支付会话持久化服务。 + */ +public interface ThirdAggregatePaySessionService extends IService { + + /** + * 按聚合会话编号读取当前租户会话。 + * + * @param aggregateNo 聚合支付会话编号 + * @return 匹配会话;不存在时返回 {@code null} + */ + ThirdAggregatePaySessionEntity getByAggregateNo(String aggregateNo); + + /** + * 按访问令牌摘要读取当前租户会话。 + * + * @param accessTokenHash 原始随机令牌的 SHA-256 摘要 + * @return 匹配会话;不存在时返回 {@code null} + */ + ThirdAggregatePaySessionEntity getByAccessTokenHash(String accessTokenHash); + + /** + * 按第三方子订单号读取当前租户聚合会话。 + * + * @param payNo {@code third_pay_order.pay_no} + * @return 绑定会话;该订单不是聚合 JSAPI 子订单时返回 {@code null} + */ + ThirdAggregatePaySessionEntity getByPayNo(String payNo); + + /** + * 查询当前租户同业务下最新创建的聚合会话。 + * + * @param bizType 支付业务类型 + * @param bizId 调用方业务订单唯一标识 + * @return 最新会话;不存在时返回 {@code null} + */ + ThirdAggregatePaySessionEntity getLatestByBiz(ThirdPayOrderBizTypeEnum bizType, String bizId); + + /** + * 首个 OAuth 回调原子锁定支付平台、付款人和预分配的第三方子订单号。 + * + * @param sessionId 聚合会话主键 + * @param platform 首个扫码选定的平台 + * @param payNo 预分配的第三方子订单号 + * @param payerIdentityHash 付款人身份摘要 + * @return 仅从等待扫码状态成功推进一次时返回 {@code true} + */ + boolean selectPlatform(Long sessionId, AppPlusPlatformTypeEnum platform, String payNo, String payerIdentityHash); + + /** + * 将已创建第三方子订单的会话原子推进为支付中。 + * + * @param sessionId 聚合会话主键 + * @param payNo 已成功保存的第三方子订单号 + * @return 仅从平台创建中状态推进成功时返回 {@code true} + */ + boolean markPaying(Long sessionId, String payNo); + + /** + * 将平台创建中的会话标记为创建失败。 + * + * @param sessionId 聚合会话主键 + * @param payNo 创建失败但已预分配的第三方子订单号 + * @return 状态首次推进成功时返回 {@code true} + */ + boolean markCreateFailed(Long sessionId, String payNo); + + /** + * 在会话尚未进入终态时原子写入指定终态。 + * + * @param sessionId 聚合会话主键 + * @param status 允许写入的成功、关闭或过期状态 + * @return 状态首次推进成功时返回 {@code true} + */ + boolean markTerminal(Long sessionId, ThirdAggregatePaySessionStatusEnum status); + + /** + * 将第三方支付子订单终态幂等同步到绑定的聚合会话。 + * + * @param payOrder 最新第三方支付子订单 + */ + void syncFromPayOrder(ThirdPayOrderEntity payOrder); +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateWechatPayService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateWechatPayService.java new file mode 100644 index 000000000..f11e1e485 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateWechatPayService.java @@ -0,0 +1,48 @@ +package com.bsd.pjcloud.thirdparty.service; + +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext; +import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import com.wechat.pay.java.service.partnerpayments.jsapi.model.Transaction; + +/** + * 微信服务商聚合订单码 OAuth 与 JSAPI 支付能力。 + */ +public interface ThirdAggregateWechatPayService { + + /** + * 使用服务商统一公众号网页授权码换取 sp_openid。 + * + * @param authCode 微信公众号 OAuth 回调授权码 + * @return 当前服务商公众号下的付款人 sp_openid + */ + String exchangeSpOpenId(String authCode); + + /** + * 创建微信支付服务商 JSAPI 子订单并保存内部支付订单。 + * + * @param context 已绑定聚合会话的支付上下文 + * @param paySetting 当前商户启用的微信子商户号配置 + * @param spOpenId 当前服务商统一公众号下的付款人 openid + * @return 微信 JSBridge 调起签名参数 + */ + ThirdAggregateCashierLaunch createPay(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting, String spOpenId); + + /** + * 查询聚合微信 JSAPI 子订单状态。 + * + * @param payNo 第三方商户订单号 + * @param settingJson 创建订单时保存的微信子商户配置快照 + * @return 微信订单状态;网络或 SDK 异常时返回 {@code null} + */ + Transaction query(String payNo, String settingJson); + + /** + * 关闭到期但尚未支付的聚合微信 JSAPI 子订单。 + * + * @param payNo 第三方商户订单号 + * @param settingJson 创建订单时保存的微信子商户配置快照 + * @return SDK 无异常完成关单时返回 {@code true} + */ + boolean close(String payNo, String settingJson); +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusAliPayService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusAliPayService.java index 7209b0a77..0cc414461 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusAliPayService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusAliPayService.java @@ -2,14 +2,20 @@ package com.bsd.pjcloud.thirdparty.service; import com.alipay.api.response.AlipayTradeQueryResponse; +import com.alipay.api.response.AlipayTradeCancelResponse; import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto; import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdMiniWechatRefundDto; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.alipay.ThirdALiPayCallbackVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo; import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import java.time.LocalDateTime; import java.util.Map; +/** + * 支付宝支付能力,封装应用支付、订单码预创建、查询、撤销及回调验真。 + */ public interface ThirdAppPlusAliPayService { /** @@ -17,6 +23,25 @@ public interface ThirdAppPlusAliPayService { */ AlipayTradeQueryResponse query(String payNo, String settingJson); + /** + * 调用支付宝当面付预创建接口并保存第三方支付订单。 + * + * @param dto 已生成支付订单号的内部支付参数 + * @param paySetting 当前商户启用的支付宝配置 + * @param expireTime 服务端二维码失效时间 + * @return 支付订单号、支付宝二维码内容串和服务端过期时间 + */ + ThirdOrderCodePayVo precreate(ThirdAppPlusPayDto dto, MchPaySettingVo paySetting, LocalDateTime expireTime); + + /** + * 撤销尚未完成的支付宝交易。 + * + * @param payNo 第三方支付服务生成的商户订单号 + * @param settingJson 创建订单时保存的商户支付配置快照 + * @return 支付宝撤销响应;网络或 SDK 异常时返回 {@code null},由上层稍后重试 + */ + AlipayTradeCancelResponse cancel(String payNo, String settingJson); + /** * app支付 */ @@ -30,7 +55,7 @@ public interface ThirdAppPlusAliPayService { /** * 支付回调 */ - ThirdPayOrderEntity payCallback(Map params); + AliPayCallbackResult payCallback(Map params); /** * 退款回调 @@ -41,4 +66,65 @@ public interface ThirdAppPlusAliPayService { * 支付消息订阅 */ ThirdALiPayCallbackVo miniSubscribeCallback(Map params, String body, Map headers); + + /** + * 支付宝回调验真及原子状态更新结果。 + */ + final class AliPayCallbackResult { + + /** + * 验真通过后重新读取的支付订单;始终属于回调中的商户订单号。 + */ + private final ThirdPayOrderEntity payOrder; + + /** + * 当前订单是否已经确认支付成功;交易关闭时为 {@code false}。 + */ + private final boolean paySuccess; + + /** + * 本次回调是否把未支付状态原子推进到终态,用于阻止重复业务处理。 + */ + private final boolean stateChanged; + + /** + * 创建支付宝回调处理结果。 + * + * @param payOrder 回调对应的最新支付订单 + * @param paySuccess 是否已经确认支付成功 + * @param stateChanged 本次回调是否完成状态推进 + */ + public AliPayCallbackResult(ThirdPayOrderEntity payOrder, boolean paySuccess, boolean stateChanged) { + this.payOrder = payOrder; + this.paySuccess = paySuccess; + this.stateChanged = stateChanged; + } + + /** + * 获取回调对应的最新支付订单。 + * + * @return 最新支付订单 + */ + public ThirdPayOrderEntity getPayOrder() { + return payOrder; + } + + /** + * 判断当前订单是否支付成功。 + * + * @return 支付成功时返回 {@code true} + */ + public boolean isPaySuccess() { + return paySuccess; + } + + /** + * 判断本次回调是否实际推进了内部状态。 + * + * @return 本次从未支付推进到终态时返回 {@code true} + */ + public boolean isStateChanged() { + return stateChanged; + } + } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusService.java index c1c1c8d43..f1288ec6a 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusService.java @@ -5,11 +5,18 @@ import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdMiniWechatRefundDt import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.vo.appplus.ThirdMinAppOAth2LoginVo; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdMiniAppPayVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayStatusVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo; import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayOrderVo; import jakarta.validation.Valid; import java.util.Map; +/** + * App、小程序及消费机第三方支付业务编排接口。 + */ public interface ThirdAppPlusService { /** @@ -37,6 +44,32 @@ public interface ThirdAppPlusService { */ String micropay(ThirdMicropayDto dto); + /** + * 创建支付宝或微信订单码支付订单;仅支持云消费机业务。 + */ + ThirdOrderCodePayVo orderCodePay(@Valid ThirdOrderCodePayDto dto); + + /** + * 查询订单码支付状态,不返回支付配置或第三方平台原始报文。 + */ + ThirdOrderCodePayStatusVo getOrderCodePayStatus(String payNo); + + /** + * 创建支付宝与微信共用的聚合订单二维码;实际平台由付款人扫码客户端决定。 + * + * @param dto 业务订单、收款商户、金额和可选用户参数 + * @return 聚合会话编号、HTTPS 二维码内容和失效时间 + */ + ThirdAggregateOrderCodePayVo aggregateOrderCodePay(@Valid ThirdAggregateOrderCodePayDto dto); + + /** + * 查询聚合订单码支付状态,不返回访问令牌、付款人标识或第三方原始报文。 + * + * @param aggregateNo 聚合支付会话编号 + * @return 聚合状态、选定平台和实际第三方子订单号 + */ + ThirdAggregateOrderCodePayStatusVo getAggregateOrderCodePayStatus(String aggregateNo); + /** * 退款 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusWechatNativePayService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusWechatNativePayService.java new file mode 100644 index 000000000..3f7247f0e --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusWechatNativePayService.java @@ -0,0 +1,44 @@ +package com.bsd.pjcloud.thirdparty.service; + +import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo; +import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction; + +import java.time.LocalDateTime; + +/** + * 微信支付服务商模式 Native 订单码服务。 + * + *

该服务只负责微信支付侧的下单、查单和关单,不承担跨平台幂等及业务状态回调。

+ */ +public interface ThirdAppPlusWechatNativePayService { + + /** + * 创建微信服务商 Native 支付订单并持久化内部支付订单。 + * + * @param dto 已生成商户订单号的内部支付参数,金额单位为人民币分 + * @param paySetting 当前子商户启用的微信支付配置 + * @param expireTime 服务端订单码过期时间,同时作为微信支付截止时间 + * @return 支付订单号、微信 {@code code_url} 内容串和服务端过期时间 + */ + ThirdOrderCodePayVo precreate(ThirdAppPlusPayDto dto, MchPaySettingVo paySetting, LocalDateTime expireTime); + + /** + * 按商户订单号查询微信服务商 Native 支付状态。 + * + * @param payNo 第三方支付服务生成的商户订单号 + * @param settingJson 创建订单时保存的子商户支付配置快照 + * @return 微信订单状态;网络、配置或 SDK 异常时返回 {@code null} 供轮询重试 + */ + Transaction query(String payNo, String settingJson); + + /** + * 关闭仍未支付的微信服务商 Native 订单。 + * + * @param payNo 第三方支付服务生成的商户订单号 + * @param settingJson 创建订单时保存的子商户支付配置快照 + * @return 微信确认关单成功时返回 {@code true};异常时返回 {@code false} 供轮询重试 + */ + boolean close(String payNo, String settingJson); +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusWechatService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusWechatService.java index a216de17e..4366dcae8 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusWechatService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusWechatService.java @@ -50,12 +50,20 @@ public interface ThirdAppPlusWechatService { ThirdMiniWechatRefundQueryVo refundQuery(String payNo); /** - * 支付的回调通知 + * 验签、解密并处理微信支付成功回调,按未支付条件原子推进本地订单。 + * + * @param body 微信支付回调原始请求体 + * @param headers 微信支付签名请求头 + * @return 已支付的内部订单;验签、字段校验或状态推进失败时返回 {@code null} */ ThirdPayOrderEntity payCallback(String body, @RequestHeader Map headers); /** - * 解析支付的回调 + * 对微信支付回调执行签名验证与 AES-GCM 解密。 + * + * @param body 微信支付回调原始请求体 + * @param headers 微信支付签名请求头 + * @return 解密后的交易信息;验签或解密失败时返回 {@code null} */ Transaction pasePayCallback(String body, Map headers); 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 new file mode 100644 index 000000000..d1c8d8621 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java @@ -0,0 +1,327 @@ +package com.bsd.pjcloud.thirdparty.service.impl.appplus; + +import cn.hutool.core.date.DatePattern; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.alipay.api.AlipayClient; +import com.alipay.api.AlipayConfig; +import com.alipay.api.DefaultAlipayClient; +import com.alipay.api.domain.AlipayTradeCreateModel; +import com.alipay.api.request.AlipaySystemOauthTokenRequest; +import com.alipay.api.request.AlipayTradeCreateRequest; +import com.alipay.api.response.AlipaySystemOauthTokenResponse; +import com.alipay.api.response.AlipayTradeCancelResponse; +import com.alipay.api.response.AlipayTradeCreateResponse; +import com.alipay.api.response.AlipayTradeQueryResponse; +import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateAlipayOauthIdentity; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateAliPayService; +import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService; +import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 支付宝聚合订单码 OAuth 与 JSAPI 支付实现。 + */ +@Service +@Validated +@Slf4j +@RequiredArgsConstructor +public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayService { + + /** + * 支付宝聚合支付日志标签,不包含授权码、用户标识或商户授权令牌。 + */ + private static final String TAG = "支付宝聚合JSAPI支付"; + + /** + * 支付宝 JSAPI 支付固定产品码。 + */ + private static final String PRODUCT_CODE = "JSAPI_PAY"; + + /** + * 支付宝账单固定安全标题,避免传递内部订单信息。 + */ + private static final String SUBJECT = "资安信消费支付"; + + /** + * 支付宝 OAuth 授权码交换固定 grant_type。 + */ + private static final String OAUTH_GRANT_TYPE = "authorization_code"; + + /** + * 支付宝服务商模式商户授权令牌公共参数名。 + */ + private static final String APP_AUTH_TOKEN_PARAM = "app_auth_token"; + + /** + * 支付宝应用与回调地址配置,来源于现有配置中心。 + */ + private final AppPlusPayConfig appPlusPayConfig; + + /** + * 第三方支付订单服务,用于保存 JSAPI 子订单及精简平台快照。 + */ + private final ThirdPayOrderService thirdPayOrderService; + + /** + * 现有支付宝支付服务,复用已验证的服务商查单和撤销能力。 + */ + private final ThirdAppPlusAliPayService thirdAppPlusAliPayService; + + /** + * 当前应用配置对应的支付宝客户端,延迟初始化并在请求间复用。 + */ + private volatile AlipayClient alipayClient; + + /** + * 使用一次性 OAuth 授权码换取支付宝付款人身份,不保存授权令牌。 + * + * @param authCode 支付宝 OAuth 回调授权码 + * @return 包含 open_id 或兼容 user_id 的付款人身份 + */ + @Override + public ThirdAggregateAlipayOauthIdentity exchangeOauthCode(String authCode) { + if (StrUtil.isBlank(authCode)) { + throw new RuntimeException("支付宝授权码不能为空"); + } + AlipaySystemOauthTokenRequest request = new AlipaySystemOauthTokenRequest(); + request.setGrantType(OAUTH_GRANT_TYPE); + request.setCode(authCode); + try { + AlipaySystemOauthTokenResponse response = getAlipayClient().execute(request); + if (response == null || !response.isSuccess()) { + String errorCode = response == null ? "EMPTY_RESPONSE" : StrUtil.firstNonBlank(response.getSubCode(), response.getCode(), "UNKNOWN"); + log.warn("|{}|支付宝用户授权失败,errorCode:{}", TAG, errorCode); + throw new RuntimeException("支付宝用户授权失败,请重新扫码"); + } + ThirdAggregateAlipayOauthIdentity identity = new ThirdAggregateAlipayOauthIdentity(response.getOpenId(), response.getUserId()); + if (StrUtil.isBlank(identity.getStableIdentity())) { + throw new RuntimeException("支付宝用户授权未返回付款人标识"); + } + return identity; + } catch (RuntimeException exception) { + throw exception; + } catch (Exception exception) { + log.warn("|{}|支付宝用户授权异常,exceptionType:{}", TAG, exception.getClass().getSimpleName()); + throw new RuntimeException("支付宝用户授权失败,请重新扫码"); + } + } + + /** + * 创建支付宝服务商 JSAPI 子订单,并仅返回 AlipayJSBridge 所需 tradeNO。 + * + * @param context 已绑定聚合会话的支付上下文 + * @param paySetting 当前商户启用的支付宝授权配置 + * @param identity 已通过 OAuth 获取的支付宝付款人身份 + * @return 支付宝 JSBridge 调起参数 + */ + @Override + public ThirdAggregateCashierLaunch createPay(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting, + ThirdAggregateAlipayOauthIdentity identity) { + MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySetting(paySetting); + ThirdPayOrderEntity payOrder = buildPayOrder(context, paySetting); + + AlipayTradeCreateModel model = new AlipayTradeCreateModel(); + model.setOutTradeNo(context.getPayNo()); + model.setTotalAmount(fenToYuan(context.getAmount())); + model.setSubject(SUBJECT); + model.setProductCode(PRODUCT_CODE); + model.setTimeExpire(LocalDateTimeUtil.format(context.getExpireTime(), DatePattern.NORM_DATETIME_PATTERN)); + if (StrUtil.isNotBlank(identity.getOpenId())) { + model.setBuyerOpenId(identity.getOpenId()); + } else { + model.setBuyerId(identity.getUserId()); + } + + AlipayTradeCreateRequest request = new AlipayTradeCreateRequest(); + AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.getAliPayConfig(); + request.setNotifyUrl(aliPayConfig.getBaseCallbackUrl() + aliPayConfig.getPayCallbackUrl()); + request.setBizModel(model); + request.putOtherTextParam(APP_AUTH_TOKEN_PARAM, aliPaySetting.getAppAuthToken()); + payOrder.setPayRep(buildRequestSnapshot(context).toString()); + + AlipayTradeCreateResponse response; + try { + response = getAlipayClient().execute(request); + } catch (Exception exception) { + saveFailedOrder(payOrder, "SDK_EXCEPTION", exception.getClass().getSimpleName()); + log.warn("|{}|支付宝JSAPI子订单创建异常,payNo:{},exceptionType:{}", TAG, context.getPayNo(), exception.getClass().getSimpleName()); + throw new RuntimeException("创建支付宝支付订单失败,请重新扫码"); + } + if (response == null || !response.isSuccess() || StrUtil.isBlank(response.getTradeNo())) { + String errorCode = response == null ? "EMPTY_RESPONSE" : StrUtil.firstNonBlank(response.getSubCode(), response.getCode(), "EMPTY_TRADE_NO"); + saveFailedOrder(payOrder, errorCode, null); + log.warn("|{}|支付宝JSAPI子订单创建失败,payNo:{},errorCode:{}", TAG, context.getPayNo(), errorCode); + throw new RuntimeException("创建支付宝支付订单失败,请重新扫码"); + } + + Map payParams = new LinkedHashMap<>(); + payParams.put("tradeNO", response.getTradeNo()); + payOrder.setPayRes(JSONUtil.createObj() + .set("aggregatePay", true) + .set("tradeNo", response.getTradeNo()) + .set("launchParams", payParams).toString()); + try { + if (!thirdPayOrderService.save(payOrder)) { + throw new RuntimeException("聚合支付子订单保存失败"); + } + } catch (RuntimeException exception) { + thirdAppPlusAliPayService.cancel(context.getPayNo(), JSONUtil.toJsonStr(paySetting)); + log.error("|{}|支付宝JSAPI子订单保存失败,已执行最佳努力撤销,payNo:{}", TAG, context.getPayNo()); + throw new RuntimeException("创建支付宝支付订单失败,请重新扫码"); + } + return new ThirdAggregateCashierLaunch(AppPlusPlatformTypeEnum.ALI_PAY, context.getPayNo(), payParams); + } + + /** + * 复用现有支付宝服务商查单实现。 + * + * @param payNo 第三方商户订单号 + * @param settingJson 创建订单时保存的商户配置快照 + * @return 支付宝查询响应;异常时由现有服务返回 {@code null} + */ + @Override + public AlipayTradeQueryResponse query(String payNo, String settingJson) { + return thirdAppPlusAliPayService.query(payNo, settingJson); + } + + /** + * 复用现有支付宝服务商撤销实现。 + * + * @param payNo 第三方商户订单号 + * @param settingJson 创建订单时保存的商户配置快照 + * @return 支付宝撤销响应;异常时由现有服务返回 {@code null} + */ + @Override + public AlipayTradeCancelResponse cancel(String payNo, String settingJson) { + return thirdAppPlusAliPayService.cancel(payNo, settingJson); + } + + /** + * 获取延迟初始化的支付宝客户端。 + * + * @return 使用现有应用密钥和支付宝公钥初始化的客户端 + */ + protected synchronized AlipayClient getAlipayClient() { + if (alipayClient == null) { + try { + AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.getAliPayConfig(); + AlipayConfig sdkConfig = new AlipayConfig(); + sdkConfig.setServerUrl("https://openapi.alipay.com/gateway.do"); + sdkConfig.setAppId(config.getAppId()); + sdkConfig.setPrivateKey(config.getAppPrivateKey()); + sdkConfig.setFormat("json"); + sdkConfig.setAlipayPublicKey(config.getAlipayPublicKey()); + sdkConfig.setCharset("UTF-8"); + sdkConfig.setSignType("RSA2"); + alipayClient = new DefaultAlipayClient(sdkConfig); + } catch (Exception exception) { + throw new RuntimeException("支付宝客户端初始化失败"); + } + } + return alipayClient; + } + + /** + * 构建不含付款人标识和商户授权令牌的请求快照。 + * + * @param context 聚合支付子订单上下文 + * @return 可安全持久化的精简请求信息 + */ + private JSONObject buildRequestSnapshot(ThirdAggregatePayCreateContext context) { + return JSONUtil.createObj() + .set("aggregatePay", true) + .set("productCode", PRODUCT_CODE) + .set("outTradeNo", context.getPayNo()) + .set("amount", context.getAmount()) + .set("expireTime", context.getExpireTime()); + } + + /** + * 构建待支付的支付宝聚合子订单。 + * + * @param context 聚合支付子订单上下文 + * @param paySetting 商户支付宝配置快照 + * @return 可持久化的未支付订单 + */ + private ThirdPayOrderEntity buildPayOrder(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting) { + ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity(); + payOrder.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType()); + payOrder.setBizType(context.getBizType().getType()); + payOrder.setBizId(context.getBizId()); + payOrder.setUserId(context.getUserId()); + payOrder.setMchId(context.getMchId()); + payOrder.setPrice(context.getAmount()); + payOrder.setPayNo(context.getPayNo()); + payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType()); + payOrder.setExpireTime(context.getExpireTime()); + payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(paySetting)); + return payOrder; + } + + /** + * 保存支付宝子订单创建失败的精简状态,不记录 SDK 原始响应或敏感配置。 + * + * @param payOrder 待保存的第三方支付订单 + * @param errorCode 支付宝错误码或本地错误标识 + * @param exceptionType 异常类型;非异常失败时为空 + */ + private void saveFailedOrder(ThirdPayOrderEntity payOrder, String errorCode, String exceptionType) { + JSONObject snapshot = JSONUtil.createObj().set("aggregatePay", true).set("code", errorCode); + if (StrUtil.isNotBlank(exceptionType)) { + snapshot.set("exceptionType", exceptionType); + } + payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_FAILED.getType()); + payOrder.setPayRes(snapshot.toString()); + thirdPayOrderService.save(payOrder); + } + + /** + * 校验商户支付宝授权配置并解析 app_auth_token。 + * + * @param paySetting 当前商户启用的支付宝支付配置 + * @return 包含非空商户授权令牌的配置 + */ + private MchPaySettingEntity.AliPaySetting parseAliPaySetting(MchPaySettingVo paySetting) { + if (paySetting == null || StrUtil.isBlank(paySetting.getSettingJson())) { + throw new RuntimeException("商户支付宝支付配置不完整"); + } + MchPaySettingEntity.AliPaySetting setting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + if (setting == null || StrUtil.isBlank(setting.getAppAuthToken())) { + throw new RuntimeException("商户支付宝授权Token未配置"); + } + return setting; + } + + /** + * 将整数人民币分精确转换为两位小数人民币元字符串。 + * + * @param amount 金额,单位为人民币分 + * @return 两位小数人民币元字符串 + */ + private String fenToYuan(Integer amount) { + if (ObjectUtil.isEmpty(amount)) { + return "0.00"; + } + return BigDecimal.valueOf(amount).movePointLeft(2).setScale(2, RoundingMode.UNNECESSARY).toPlainString(); + } +} 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 new file mode 100644 index 000000000..8559daced --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java @@ -0,0 +1,1214 @@ +package com.bsd.pjcloud.thirdparty.service.impl.appplus; + +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.RandomUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.alipay.api.response.AlipayTradeCancelResponse; +import com.alipay.api.response.AlipayTradeQueryResponse; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.bsd.pjcloud.admin.api.entity.SysTenant; +import com.bsd.pjcloud.admin.api.feign.RemoteTenantService; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateAlipayOauthIdentity; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierPageData; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateAliPayService; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateWechatPayService; +import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import com.wechat.pay.java.service.partnerpayments.jsapi.model.Transaction; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jasypt.encryption.StringEncryptor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +/** + * 支付宝与微信聚合订单码支付编排服务实现。 + */ +@Service +@Validated +@Slf4j +@RequiredArgsConstructor +public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPayService { + + /** + * 聚合支付日志标签,不包含二维码、访问令牌、OAuth code 或付款人标识。 + */ + private static final String TAG = "支付宝微信聚合订单码"; + + /** + * 聚合二维码协议类型,调用方据此与自有钱包二维码严格区分。 + */ + private static final String QR_CODE_TYPE = "AGGREGATE_ORDER_PAY"; + + /** + * thirdparty 服务经网关暴露的固定公网前缀,网关转发后会移除该前缀。 + */ + private static final String PUBLIC_ROUTE_PREFIX = "/third"; + + /** + * 匿名消费机订单的内部用户ID占位值。 + */ + private static final long ANONYMOUS_USER_ID = -1L; + + /** + * 聚合二维码和第三方子订单有效时长,单位分钟。 + */ + private static final long EXPIRE_MINUTES = 2L; + + /** + * 订单码创建共享互斥锁前缀,旧原生码和聚合码使用同一键以阻止并发双下单。 + * 租户前缀由 Redis 基础设施自动附加。 + */ + private static final String CREATE_LOCK_PREFIX = "thirdparty:order-code-pay:lock:"; + + /** + * OAuth state Redis 键前缀,租户前缀由 Redis 基础设施自动附加。 + */ + private static final String OAUTH_STATE_PREFIX = "thirdparty:aggregate-order-pay:oauth-state:"; + + /** + * 收银页支付状态短期 ticket Redis 键前缀,租户前缀由 Redis 基础设施自动附加。 + */ + private static final String STATUS_TICKET_PREFIX = "thirdparty:aggregate-order-pay:status-ticket:"; + + /** + * 聚合码创建锁最长持有时间,覆盖商户、租户和配置远程读取。 + */ + private static final Duration CREATE_LOCK_TTL = Duration.ofSeconds(30); + + /** + * OAuth state 有效期,覆盖正常扫码授权跳转但限制授权码重放窗口。 + */ + private static final Duration OAUTH_STATE_TTL = Duration.ofMinutes(5); + + /** + * 收银页状态查询 ticket 有效期,覆盖二维码支付和结果展示周期。 + */ + private static final Duration STATUS_TICKET_TTL = Duration.ofMinutes(5); + + /** + * 收银 URL 原始随机访问令牌字节数,提供 256 位随机强度。 + */ + private static final int ACCESS_TOKEN_BYTES = 32; + + /** + * 支付宝用户授权入口。 + */ + private static final String ALIPAY_OAUTH_URL = "https://openauth.alipay.com/oauth2/publicAppAuthorize.htm"; + + /** + * 微信公众号网页授权入口。 + */ + private static final String WECHAT_OAUTH_URL = "https://open.weixin.qq.com/connect/oauth2/authorize"; + + /** + * 支付宝 OAuth 最小静默授权范围。 + */ + private static final String ALIPAY_OAUTH_SCOPE = "auth_base"; + + /** + * 微信公众号 OAuth 最小静默授权范围。 + */ + private static final String WECHAT_OAUTH_SCOPE = "snsapi_base"; + + /** + * 支付宝撤销接口要求稍后重试的标记值。 + */ + private static final String ALIPAY_CANCEL_RETRY = "Y"; + + /** + * 仅当 Redis 锁值仍属于当前请求时删除锁,避免误删后继请求锁。 + */ + private static final DefaultRedisScript UNLOCK_SCRIPT = new DefaultRedisScript<>( + "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", Long.class); + + /** + * 聚合会话服务,持有扫码选平台和第三方子订单绑定状态。 + */ + private final ThirdAggregatePaySessionService aggregatePaySessionService; + + /** + * 支付宝 OAuth、JSAPI 下单、查单和撤销服务。 + */ + private final ThirdAggregateAliPayService aggregateAliPayService; + + /** + * 微信服务商统一公众号 OAuth、JSAPI 下单、查单和关单服务。 + */ + private final ThirdAggregateWechatPayService aggregateWechatPayService; + + /** + * 第三方支付订单服务,持有实际支付宝或微信子订单状态。 + */ + private final ThirdPayOrderService thirdPayOrderService; + + /** + * 钱包商户远程服务,用于读取当前租户收款商户及启用支付配置。 + */ + private final RemoteMchService remoteMchService; + + /** + * 租户远程服务,用于租户ID与公网 URL 唯一编码之间的映射。 + */ + private final RemoteTenantService remoteTenantService; + + /** + * 现有支付宝、微信应用级支付和公网回调地址配置。 + */ + private final AppPlusPayConfig appPlusPayConfig; + + /** + * Redis 字符串模板,用于短锁、一次性 OAuth state 和短期状态 ticket。 + */ + private final StringRedisTemplate stringRedisTemplate; + + /** + * 项目统一字符串加密器,用于聚合访问令牌密文的保存和幂等恢复。 + */ + private final StringEncryptor stringEncryptor; + + /** + * 进程级强随机数生成器,用于不可预测的聚合访问令牌。 + */ + private final SecureRandom secureRandom = new SecureRandom(); + + /** + * 创建或复用同业务有效聚合二维码,并在创建前完整校验双平台配置。 + * + * @param dto 业务订单、商户、金额和可选用户参数 + * @return 聚合会话编号、HTTPS 二维码内容和失效时间 + */ + @Override + public ThirdAggregateOrderCodePayVo create(ThirdAggregateOrderCodePayDto dto) { + validateCreateRequest(dto); + String lockKey = CREATE_LOCK_PREFIX + dto.getBizType().getType() + ":" + dto.getBizId(); + String lockToken = UUID.randomUUID().toString(); + Boolean acquired = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockToken, CREATE_LOCK_TTL); + if (!Boolean.TRUE.equals(acquired)) { + throw new RuntimeException("聚合订单码正在处理中,请勿重复提交"); + } + try { + return createOrReuse(dto); + } finally { + releaseLock(lockKey, lockToken); + } + } + + /** + * 查询当前租户聚合会话,并先同步已到达终态的第三方子订单。 + * + * @param aggregateNo 聚合支付会话编号 + * @return 最小聚合支付状态 + */ + @Override + public ThirdAggregateOrderCodePayStatusVo getStatus(String aggregateNo) { + if (StrUtil.isBlank(aggregateNo)) { + throw new RuntimeException("聚合支付会话编号不能为空"); + } + ThirdAggregatePaySessionEntity session = aggregatePaySessionService.getByAggregateNo(aggregateNo); + if (session == null) { + throw new RuntimeException("聚合支付会话不存在"); + } + syncSessionFromChildOrder(session); + return buildStatusVo(aggregatePaySessionService.getById(session.getId())); + } + + /** + * 旧平台原生订单码创建前执行聚合会话互斥检查,已过期会话先尝试收敛终态。 + * + * @param bizType 支付业务类型 + * @param bizId 调用方业务订单唯一标识 + */ + @Override + public void assertNoBlockingAggregateSession(ThirdPayOrderBizTypeEnum bizType, String bizId) { + ThirdAggregatePaySessionEntity session = aggregatePaySessionService.getLatestByBiz(bizType, bizId); + if (session == null) { + return; + } + if (ObjectUtil.equals(session.getStatus(), ThirdAggregatePaySessionStatusEnum.PAY_SUCCESS.getType())) { + throw new RuntimeException("该业务订单已通过聚合码支付"); + } + if (isActive(session) && isExpired(session)) { + processPendingSession(session.getId()); + session = aggregatePaySessionService.getById(session.getId()); + } + if (session != null && isActive(session)) { + throw new RuntimeException("该业务订单已有待支付的聚合订单码"); + } + } + + /** + * 在租户上下文中校验聚合访问令牌和扫码客户端,然后创建一次性 OAuth state。 + * + * @param tenantCode 聚合 URL 中的租户唯一编码 + * @param token 聚合 URL 随机访问令牌 + * @param userAgent 扫码客户端 User-Agent + * @return 支付宝或微信官方 OAuth 授权地址 + */ + @Override + public String getCashierOauthUrl(String tenantCode, String token, String userAgent) { + SysTenant tenant = resolveTenant(tenantCode); + return TenantBroker.applyAs(tenant.getId(), ignored -> { + AppPlusPlatformTypeEnum platform = resolveClientPlatform(userAgent); + ThirdAggregatePaySessionEntity session = aggregatePaySessionService.getByAccessTokenHash(sha256(token)); + validateCashierSession(session, platform); + String state = createOauthState(session.getId(), platform); + String callbackUrl = buildPublicUrl(getPlatformBaseUrl(platform), "/aggregate-order-pay/oauth/" + + platformPath(platform) + "/" + tenantCode); + if (ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.WECHAT)) { + return WECHAT_OAUTH_URL + "?appid=" + encode(appPlusPayConfig.getWechatConfig().getGzhAppId()) + + "&redirect_uri=" + encode(callbackUrl) + + "&response_type=code&scope=" + WECHAT_OAUTH_SCOPE + + "&state=" + encode(state) + "#wechat_redirect"; + } + return ALIPAY_OAUTH_URL + "?app_id=" + encode(appPlusPayConfig.getAliPayConfig().getAppId()) + + "&scope=" + ALIPAY_OAUTH_SCOPE + + "&redirect_uri=" + encode(callbackUrl) + + "&state=" + encode(state); + }); + } + + /** + * 消费一次性微信 OAuth state,换取服务商公众号 sp_openid 并创建或复用 JSAPI 子订单。 + * + * @param tenantCode 聚合 URL 中的租户唯一编码 + * @param authCode 微信一次性授权码 + * @param state 服务端创建的一次性 OAuth state + * @return 微信 JSBridge 调起数据和短期状态查询地址 + */ + @Override + public ThirdAggregateCashierPageData handleWechatOauth(String tenantCode, String authCode, String state) { + SysTenant tenant = resolveTenant(tenantCode); + return TenantBroker.applyAs(tenant.getId(), ignored -> { + ThirdAggregatePaySessionEntity session = consumeOauthState(state, AppPlusPlatformTypeEnum.WECHAT); + String spOpenId = aggregateWechatPayService.exchangeSpOpenId(authCode); + ThirdAggregateCashierLaunch launch = createOrReuseChildOrder(session, AppPlusPlatformTypeEnum.WECHAT, + spOpenId, null); + return buildCashierPageData(tenantCode, session.getAggregateNo(), launch); + }); + } + + /** + * 消费一次性支付宝 OAuth state,换取付款人身份并创建或复用 JSAPI 子订单。 + * + * @param tenantCode 聚合 URL 中的租户唯一编码 + * @param authCode 支付宝一次性授权码 + * @param state 服务端创建的一次性 OAuth state + * @return 支付宝 JSBridge 调起数据和短期状态查询地址 + */ + @Override + public ThirdAggregateCashierPageData handleAlipayOauth(String tenantCode, String authCode, String state) { + SysTenant tenant = resolveTenant(tenantCode); + return TenantBroker.applyAs(tenant.getId(), ignored -> { + ThirdAggregatePaySessionEntity session = consumeOauthState(state, AppPlusPlatformTypeEnum.ALI_PAY); + ThirdAggregateAlipayOauthIdentity identity = aggregateAliPayService.exchangeOauthCode(authCode); + ThirdAggregateCashierLaunch launch = createOrReuseChildOrder(session, AppPlusPlatformTypeEnum.ALI_PAY, + identity.getStableIdentity(), identity); + return buildCashierPageData(tenantCode, session.getAggregateNo(), launch); + }); + } + + /** + * 在 URL 指定租户上下文中使用短期 ticket 查询聚合支付状态。 + * + * @param tenantCode 聚合 URL 中的租户唯一编码 + * @param ticket OAuth 回调收银页取得的短期状态票据 + * @return 不含敏感字段的聚合支付状态 + */ + @Override + public ThirdAggregateOrderCodePayStatusVo getStatusByTicket(String tenantCode, String ticket) { + SysTenant tenant = resolveTenant(tenantCode); + return TenantBroker.applyAs(tenant.getId(), ignored -> { + if (StrUtil.isBlank(ticket)) { + throw new RuntimeException("支付状态票据不能为空"); + } + String aggregateNo = stringRedisTemplate.opsForValue().get(STATUS_TICKET_PREFIX + ticket); + if (StrUtil.isBlank(aggregateNo)) { + throw new RuntimeException("支付状态票据已失效"); + } + return getStatus(aggregateNo); + }); + } + + /** + * 处理一条待确认聚合会话,平台网络异常或可重试结果保留当前状态供下轮处理。 + * + * @param sessionId 聚合支付会话主键 + */ + @Override + public void processPendingSession(Long sessionId) { + ThirdAggregatePaySessionEntity session = aggregatePaySessionService.getById(sessionId); + if (session == null || !isActive(session)) { + return; + } + if (ObjectUtil.equals(session.getStatus(), ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType())) { + if (isExpired(session)) { + aggregatePaySessionService.markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.EXPIRED); + } + return; + } + if (StrUtil.isBlank(session.getPayNo())) { + if (isExpired(session)) { + aggregatePaySessionService.markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.EXPIRED); + } + return; + } + ThirdPayOrderEntity payOrder = thirdPayOrderService.getByPayNo(session.getPayNo()); + if (payOrder == null) { + if (isExpired(session)) { + aggregatePaySessionService.markCreateFailed(session.getId(), session.getPayNo()); + } + return; + } + aggregatePaySessionService.syncFromPayOrder(payOrder); + if (ObjectUtil.notEqual(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) { + return; + } + if (ObjectUtil.equals(session.getSelectedPlatform(), AppPlusPlatformTypeEnum.ALI_PAY.getType())) { + processAlipayOrder(session, payOrder); + } else if (ObjectUtil.equals(session.getSelectedPlatform(), AppPlusPlatformTypeEnum.WECHAT.getType())) { + processWechatOrder(session, payOrder); + } + } + + /** + * 识别共享回调中的聚合 JSAPI 子订单并同步聚合会话,旧支付订单返回 {@code false}。 + * + * @param payOrder 已通过平台验签和关键字段校验的最新第三方支付订单 + * @return 找到 pay_no 绑定聚合会话时返回 {@code true} + */ + @Override + public boolean syncAggregateChildOrder(ThirdPayOrderEntity payOrder) { + if (payOrder == null || StrUtil.isBlank(payOrder.getPayNo())) { + return false; + } + ThirdAggregatePaySessionEntity session = aggregatePaySessionService.getByPayNo(payOrder.getPayNo()); + if (session == null) { + return false; + } + aggregatePaySessionService.syncFromPayOrder(payOrder); + return true; + } + + /** + * 在持有创建锁时复用有效聚合会话或创建新会话。 + * + * @param dto 已校验聚合支付创建请求 + * @return 可直接渲染为二维码的聚合 HTTPS 地址 + */ + private ThirdAggregateOrderCodePayVo createOrReuse(ThirdAggregateOrderCodePayDto dto) { + ThirdAggregatePaySessionEntity latest = aggregatePaySessionService.getLatestByBiz(dto.getBizType(), dto.getBizId()); + if (latest != null && ObjectUtil.equals(latest.getStatus(), ThirdAggregatePaySessionStatusEnum.PAY_SUCCESS.getType())) { + throw new RuntimeException("该业务订单已支付,请勿重复下单"); + } + if (latest != null && isActive(latest)) { + validateReusableSession(latest, dto); + if (!isExpired(latest)) { + return buildCreateVo(latest, decryptAccessToken(latest), getTenantCode(latest.getTenantId())); + } + processPendingSession(latest.getId()); + latest = aggregatePaySessionService.getById(latest.getId()); + if (latest != null && ObjectUtil.equals(latest.getStatus(), ThirdAggregatePaySessionStatusEnum.PAY_SUCCESS.getType())) { + throw new RuntimeException("该业务订单已支付,请勿重复下单"); + } + if (latest != null && isActive(latest)) { + throw new RuntimeException("上一笔聚合订单正在确认关闭,请稍后重试"); + } + } + + assertNoBlockingNativeOrder(dto); + MchManageVo merchant = getMerchant(dto.getMchId()); + getRequiredAliPaySetting(merchant); + getRequiredWechatPaySetting(merchant); + validateApplicationConfig(); + String tenantCode = getTenantCode(merchant.getTenantId()); + + String accessToken = createRandomToken(ACCESS_TOKEN_BYTES); + ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity(); + session.setAggregateNo(createAggregateNo()); + session.setAccessTokenHash(sha256(accessToken)); + session.setAccessTokenCiphertext(stringEncryptor.encrypt(accessToken)); + session.setBizType(dto.getBizType().getType()); + session.setBizId(dto.getBizId()); + session.setUserId(dto.getUserId() == null ? ANONYMOUS_USER_ID : dto.getUserId()); + session.setMchId(merchant.getId()); + session.setPrice(dto.getAmount()); + session.setStatus(ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType()); + session.setExpireTime(LocalDateTime.now().plusMinutes(EXPIRE_MINUTES)); + if (!aggregatePaySessionService.save(session)) { + throw new RuntimeException("聚合支付会话保存失败,请稍后重试"); + } + return buildCreateVo(session, accessToken, tenantCode); + } + + /** + * 原子选定平台后创建第三方子订单,或为同平台同付款人恢复已存在的调起参数。 + * + * @param originalSession OAuth state 绑定的会话快照 + * @param platform 当前 OAuth 平台 + * @param payerIdentity 付款人原始身份,仅用于摘要比较 + * @param alipayIdentity 支付宝 OAuth 身份;微信平台时为空 + * @return 当前付款人的 JSBridge 调起参数 + */ + private ThirdAggregateCashierLaunch createOrReuseChildOrder(ThirdAggregatePaySessionEntity originalSession, + AppPlusPlatformTypeEnum platform, String payerIdentity, + ThirdAggregateAlipayOauthIdentity alipayIdentity) { + ThirdAggregatePaySessionEntity session = aggregatePaySessionService.getById(originalSession.getId()); + validateCashierSession(session, platform); + String payerHash = sha256(payerIdentity); + if (StrUtil.isNotBlank(session.getSelectedPlatform())) { + validateSelectedPayer(session, platform, payerHash); + if (ObjectUtil.equals(session.getStatus(), ThirdAggregatePaySessionStatusEnum.PAYING.getType())) { + return restoreLaunch(session); + } + throw new RuntimeException("支付订单正在创建,请稍后重新扫码"); + } + + String payNo = createPayNo(); + if (!aggregatePaySessionService.selectPlatform(session.getId(), platform, payNo, payerHash)) { + ThirdAggregatePaySessionEntity latest = aggregatePaySessionService.getById(session.getId()); + validateSelectedPayer(latest, platform, payerHash); + if (ObjectUtil.equals(latest.getStatus(), ThirdAggregatePaySessionStatusEnum.PAYING.getType())) { + return restoreLaunch(latest); + } + throw new RuntimeException("支付订单正在创建,请稍后重新扫码"); + } + + ThirdAggregatePayCreateContext context = buildCreateContext(session, payNo); + MchManageVo merchant = getMerchant(session.getMchId()); + try { + ThirdAggregateCashierLaunch launch; + if (ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.ALI_PAY)) { + launch = aggregateAliPayService.createPay(context, getRequiredAliPaySetting(merchant), alipayIdentity); + } else { + launch = aggregateWechatPayService.createPay(context, getRequiredWechatPaySetting(merchant), payerIdentity); + } + if (!aggregatePaySessionService.markPaying(session.getId(), payNo)) { + ThirdPayOrderEntity latestPayOrder = thirdPayOrderService.getByPayNo(payNo); + ThirdAggregatePaySessionEntity latestSession = aggregatePaySessionService.getById(session.getId()); + if ((latestSession != null && ObjectUtil.equals(latestSession.getStatus(), + ThirdAggregatePaySessionStatusEnum.PAY_SUCCESS.getType())) + || (latestPayOrder != null && ObjectUtil.equals(latestPayOrder.getStatus(), + ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()))) { + aggregatePaySessionService.syncFromPayOrder(latestPayOrder); + return launch; + } + closeOrCancelChildOrder(latestPayOrder); + throw new RuntimeException("聚合支付会话状态已变化,请重新创建二维码"); + } + return launch; + } catch (RuntimeException exception) { + aggregatePaySessionService.markCreateFailed(session.getId(), payNo); + throw exception; + } + } + + /** + * 查询支付宝子订单并根据交易状态原子同步成功、关闭或到期撤销结果。 + * + * @param session 当前聚合会话 + * @param payOrder 当前未支付支付宝子订单 + */ + private void processAlipayOrder(ThirdAggregatePaySessionEntity session, ThirdPayOrderEntity payOrder) { + AlipayTradeQueryResponse response = aggregateAliPayService.query(payOrder.getPayNo(), payOrder.getMchPaySettingJson()); + if (response != null && response.isSuccess()) { + String tradeStatus = response.getTradeStatus(); + if (ObjectUtil.equals(tradeStatus, "TRADE_SUCCESS") || ObjectUtil.equals(tradeStatus, "TRADE_FINISHED")) { + updateChildOrderSuccess(payOrder, response.getTradeNo()); + syncSessionFromChildOrder(session); + return; + } + if (ObjectUtil.equals(tradeStatus, "TRADE_CLOSED")) { + updateChildOrderClosed(payOrder.getId()); + syncSessionFromChildOrder(session); + return; + } + } + if (!isExpired(session)) { + return; + } + AlipayTradeCancelResponse cancelResponse = aggregateAliPayService.cancel(payOrder.getPayNo(), payOrder.getMchPaySettingJson()); + if (cancelResponse != null && cancelResponse.isSuccess() + && ObjectUtil.notEqual(cancelResponse.getRetryFlag(), ALIPAY_CANCEL_RETRY) + && (ObjectUtil.equals(cancelResponse.getAction(), "close") || ObjectUtil.equals(cancelResponse.getAction(), "refund"))) { + updateChildOrderClosed(payOrder.getId()); + syncSessionFromChildOrder(session); + } + } + + /** + * 查询微信 JSAPI 子订单并根据交易状态原子同步成功、关闭或到期关单结果。 + * + * @param session 当前聚合会话 + * @param payOrder 当前未支付微信子订单 + */ + private void processWechatOrder(ThirdAggregatePaySessionEntity session, ThirdPayOrderEntity payOrder) { + Transaction transaction = aggregateWechatPayService.query(payOrder.getPayNo(), payOrder.getMchPaySettingJson()); + if (transaction != null && transaction.getTradeState() != null) { + if (ObjectUtil.equals(transaction.getTradeState(), Transaction.TradeStateEnum.SUCCESS)) { + updateChildOrderSuccess(payOrder, transaction.getTransactionId()); + syncSessionFromChildOrder(session); + return; + } + if (ObjectUtil.equals(transaction.getTradeState(), Transaction.TradeStateEnum.CLOSED) + || ObjectUtil.equals(transaction.getTradeState(), Transaction.TradeStateEnum.REVOKED) + || ObjectUtil.equals(transaction.getTradeState(), Transaction.TradeStateEnum.REFUND)) { + updateChildOrderClosed(payOrder.getId()); + syncSessionFromChildOrder(session); + return; + } + if (ObjectUtil.equals(transaction.getTradeState(), Transaction.TradeStateEnum.PAYERROR)) { + updateChildOrderFailed(payOrder.getId()); + aggregatePaySessionService.markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED); + return; + } + } + if (isExpired(session) && aggregateWechatPayService.close(payOrder.getPayNo(), payOrder.getMchPaySettingJson())) { + updateChildOrderClosed(payOrder.getId()); + syncSessionFromChildOrder(session); + } + } + + /** + * 从第三方子订单状态同步聚合会话,状态更新由会话服务使用 CAS 保证幂等。 + * + * @param session 当前聚合会话 + */ + private void syncSessionFromChildOrder(ThirdAggregatePaySessionEntity session) { + if (session == null || StrUtil.isBlank(session.getPayNo())) { + return; + } + aggregatePaySessionService.syncFromPayOrder(thirdPayOrderService.getByPayNo(session.getPayNo())); + } + + /** + * 将未支付第三方子订单原子推进为支付成功并记录平台交易号。 + * + * @param payOrder 当前未支付子订单 + * @param thirdPayNo 第三方平台交易号 + */ + private void updateChildOrderSuccess(ThirdPayOrderEntity payOrder, String thirdPayNo) { + ThirdPayOrderEntity update = new ThirdPayOrderEntity(); + update.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); + update.setThirdPayNo(thirdPayNo); + update.setPayTime(LocalDateTime.now()); + thirdPayOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, payOrder.getId()) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType())); + } + + /** + * 将未支付第三方子订单原子推进为交易关闭。 + * + * @param payOrderId 第三方支付订单主键 + */ + private void updateChildOrderClosed(Long payOrderId) { + ThirdPayOrderEntity update = new ThirdPayOrderEntity(); + update.setStatus(ThirdPayOrderStatusEnum.PAY_CLOSED.getType()); + thirdPayOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, payOrderId) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType())); + } + + /** + * 将微信明确返回 PAYERROR 的未支付子订单原子推进为支付失败。 + * + * @param payOrderId 第三方支付订单主键 + */ + private void updateChildOrderFailed(Long payOrderId) { + ThirdPayOrderEntity update = new ThirdPayOrderEntity(); + update.setStatus(ThirdPayOrderStatusEnum.PAY_FAILED.getType()); + thirdPayOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, payOrderId) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType())); + } + + /** + * 对已创建但会话绑定失败的子订单执行最佳努力撤销或关单。 + * + * @param payOrder 需要关闭的第三方子订单,允许为空 + */ + private void closeOrCancelChildOrder(ThirdPayOrderEntity payOrder) { + if (payOrder == null || ObjectUtil.notEqual(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) { + return; + } + boolean closed = false; + if (ObjectUtil.equals(payOrder.getPayPlatformType(), AppPlusPlatformTypeEnum.WECHAT.getType())) { + closed = aggregateWechatPayService.close(payOrder.getPayNo(), payOrder.getMchPaySettingJson()); + } else { + AlipayTradeCancelResponse response = aggregateAliPayService.cancel(payOrder.getPayNo(), payOrder.getMchPaySettingJson()); + closed = response != null && response.isSuccess() && ObjectUtil.notEqual(response.getRetryFlag(), ALIPAY_CANCEL_RETRY); + } + if (closed) { + updateChildOrderClosed(payOrder.getId()); + } + } + + /** + * 从已支付中会话绑定的子订单响应快照恢复 JSBridge 调起参数。 + * + * @param session 已选定平台且状态为支付中的聚合会话 + * @return 原平台 JSBridge 调起参数 + */ + private ThirdAggregateCashierLaunch restoreLaunch(ThirdAggregatePaySessionEntity session) { + ThirdPayOrderEntity payOrder = thirdPayOrderService.getByPayNo(session.getPayNo()); + if (payOrder == null || StrUtil.isBlank(payOrder.getPayRes())) { + throw new RuntimeException("支付订单调起参数不存在,请重新创建二维码"); + } + try { + JSONObject launchObject = JSONUtil.parseObj(payOrder.getPayRes()).getJSONObject("launchParams"); + if (launchObject == null || launchObject.isEmpty()) { + throw new RuntimeException("支付订单调起参数不存在,请重新创建二维码"); + } + Map params = new LinkedHashMap<>(); + launchObject.forEach(params::put); + return new ThirdAggregateCashierLaunch(AppPlusPlatformTypeEnum.valueOfByType(session.getSelectedPlatform()), + session.getPayNo(), params); + } catch (RuntimeException exception) { + throw exception; + } catch (Exception exception) { + throw new RuntimeException("支付订单调起参数无效,请重新创建二维码"); + } + } + + /** + * 创建短期状态 ticket 并组装收银页数据。 + * + * @param tenantCode 当前租户唯一编码 + * @param aggregateNo 聚合支付会话编号 + * @param launch JSBridge 调起数据 + * @return 收银页调起和状态查询数据 + */ + private ThirdAggregateCashierPageData buildCashierPageData(String tenantCode, String aggregateNo, + ThirdAggregateCashierLaunch launch) { + String ticket = createRandomToken(24); + stringRedisTemplate.opsForValue().set(STATUS_TICKET_PREFIX + ticket, aggregateNo, STATUS_TICKET_TTL); + String statusUrl = buildPublicUrl(getPlatformBaseUrl(launch.getPlatform()), + "/aggregate-order-pay/status/" + tenantCode + "/" + ticket); + return new ThirdAggregateCashierPageData(launch, statusUrl); + } + + /** + * 消费并校验一次性 OAuth state,读取其绑定的聚合会话。 + * + * @param state OAuth 回调 state + * @param expectedPlatform 当前回调平台 + * @return state 绑定且尚未过期的聚合会话 + */ + private ThirdAggregatePaySessionEntity consumeOauthState(String state, AppPlusPlatformTypeEnum expectedPlatform) { + if (StrUtil.isBlank(state)) { + throw new RuntimeException("支付授权状态不能为空"); + } + String value = stringRedisTemplate.opsForValue().getAndDelete(OAUTH_STATE_PREFIX + state); + if (StrUtil.isBlank(value)) { + throw new RuntimeException("支付授权状态已失效,请重新扫码"); + } + JSONObject stateObject = JSONUtil.parseObj(value); + if (ObjectUtil.notEqual(stateObject.getStr("platform"), expectedPlatform.getType())) { + throw new RuntimeException("支付授权平台不匹配"); + } + ThirdAggregatePaySessionEntity session = aggregatePaySessionService.getById(stateObject.getLong("sessionId")); + validateCashierSession(session, expectedPlatform); + return session; + } + + /** + * 创建一次性 OAuth state 并绑定会话主键和预期平台。 + * + * @param sessionId 聚合会话主键 + * @param platform 扫码客户端平台 + * @return 不可预测的一次性 state + */ + private String createOauthState(Long sessionId, AppPlusPlatformTypeEnum platform) { + String state = createRandomToken(24); + String value = JSONUtil.createObj().set("sessionId", sessionId).set("platform", platform.getType()).toString(); + stringRedisTemplate.opsForValue().set(OAUTH_STATE_PREFIX + state, value, OAUTH_STATE_TTL); + return state; + } + + /** + * 校验聚合会话可继续扫码,并阻止另一平台在首个平台已选定后继续授权。 + * + * @param session 访问令牌或 OAuth state 定位的聚合会话 + * @param platform 当前扫码客户端平台 + */ + private void validateCashierSession(ThirdAggregatePaySessionEntity session, AppPlusPlatformTypeEnum platform) { + if (session == null) { + throw new RuntimeException("聚合二维码无效"); + } + if (ObjectUtil.equals(session.getStatus(), ThirdAggregatePaySessionStatusEnum.PAY_SUCCESS.getType())) { + throw new RuntimeException("订单已支付成功"); + } + if (isExpired(session)) { + processPendingSession(session.getId()); + throw new RuntimeException("聚合二维码已过期"); + } + if (!isActive(session)) { + throw new RuntimeException("聚合二维码已失效"); + } + if (StrUtil.isNotBlank(session.getSelectedPlatform()) + && ObjectUtil.notEqual(session.getSelectedPlatform(), platform.getType())) { + throw new RuntimeException("该聚合二维码已由其他支付平台锁定"); + } + } + + /** + * 校验已选定会话只能由同一平台、同一付款人恢复调起参数。 + * + * @param session 已选定平台的聚合会话 + * @param platform 当前 OAuth 平台 + * @param payerHash 当前付款人身份摘要 + */ + private void validateSelectedPayer(ThirdAggregatePaySessionEntity session, AppPlusPlatformTypeEnum platform, String payerHash) { + if (session == null || ObjectUtil.notEqual(session.getSelectedPlatform(), platform.getType())) { + throw new RuntimeException("该聚合二维码已由其他支付平台锁定"); + } + if (ObjectUtil.notEqual(session.getPayerIdentityHash(), payerHash)) { + throw new RuntimeException("该聚合二维码已由其他付款人使用"); + } + } + + /** + * 判断会话是否仍可能创建或完成付款。 + * + * @param session 聚合支付会话 + * @return 等待扫码、平台创建中或支付中时返回 {@code true} + */ + private boolean isActive(ThirdAggregatePaySessionEntity session) { + return session != null && List.of( + ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(), + ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(), + ThirdAggregatePaySessionStatusEnum.PAYING.getType()).contains(session.getStatus()); + } + + /** + * 判断聚合会话是否已经到达服务端失效时间。 + * + * @param session 聚合支付会话 + * @return 失效时间为空或不晚于当前时间时返回 {@code true} + */ + private boolean isExpired(ThirdAggregatePaySessionEntity session) { + return session == null || session.getExpireTime() == null || !session.getExpireTime().isAfter(LocalDateTime.now()); + } + + /** + * 阻止同业务存在未归属于聚合会话的旧原生订单码或既有成功订单。 + * + * @param dto 当前聚合支付创建请求 + */ + private void assertNoBlockingNativeOrder(ThirdAggregateOrderCodePayDto dto) { + List orders = thirdPayOrderService.list(new LambdaQueryWrapper() + .eq(ThirdPayOrderEntity::getBizType, dto.getBizType().getType()) + .eq(ThirdPayOrderEntity::getBizId, dto.getBizId()) + .in(ThirdPayOrderEntity::getPayPlatformType, + AppPlusPlatformTypeEnum.ALI_PAY.getType(), AppPlusPlatformTypeEnum.WECHAT.getType()) + .isNotNull(ThirdPayOrderEntity::getExpireTime) + .in(ThirdPayOrderEntity::getStatus, + ThirdPayOrderStatusEnum.PAY_UNPAID.getType(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()) + .orderByDesc(ThirdPayOrderEntity::getCreateTime)); + for (ThirdPayOrderEntity order : orders) { + if (aggregatePaySessionService.getByPayNo(order.getPayNo()) != null) { + continue; + } + if (ObjectUtil.equals(order.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) { + throw new RuntimeException("该业务订单已通过平台原生订单码支付"); + } + throw new RuntimeException("该业务订单已有待确认的平台原生订单码"); + } + } + + /** + * 校验聚合支付创建参数的业务范围和金额边界。 + * + * @param dto 聚合支付创建请求 + */ + private void validateCreateRequest(ThirdAggregateOrderCodePayDto dto) { + if (dto == null || ObjectUtil.notEqual(dto.getBizType(), ThirdPayOrderBizTypeEnum.YXFJ_PAY)) { + throw new RuntimeException("聚合订单码仅支持云消费机支付业务"); + } + if (StrUtil.isBlank(dto.getBizId()) || dto.getMchId() == null || dto.getAmount() == null || dto.getAmount() <= 0) { + throw new RuntimeException("聚合订单码支付参数不完整"); + } + } + + /** + * 校验可复用会话的商户和金额与当前请求完全一致。 + * + * @param session 最新有效聚合会话 + * @param dto 当前创建请求 + */ + private void validateReusableSession(ThirdAggregatePaySessionEntity session, ThirdAggregateOrderCodePayDto dto) { + if (ObjectUtil.notEqual(session.getMchId(), dto.getMchId()) || ObjectUtil.notEqual(session.getPrice(), dto.getAmount())) { + throw new RuntimeException("该业务订单已有不同金额或商户的聚合订单码"); + } + } + + /** + * 读取当前租户收款商户并检查远程业务响应。 + * + * @param mchId 收款商户ID + * @return 包含支付配置列表的商户详情 + */ + private MchManageVo getMerchant(Long mchId) { + R result = remoteMchService.getById(mchId); + MchManageVo merchant = result == null || !result.isOk() ? null : result.getData(); + if (merchant == null || ObjectUtil.notEqual(merchant.getId(), mchId)) { + throw new RuntimeException("收款商户不存在"); + } + if (ObjectUtil.isEmpty(merchant.getPaySettingList())) { + throw new RuntimeException("商户未配置支付参数"); + } + return merchant; + } + + /** + * 选择已启用且包含 app_auth_token 的支付宝商户配置。 + * + * @param merchant 收款商户详情 + * @return 当前商户支付宝配置 + */ + private MchPaySettingVo getRequiredAliPaySetting(MchManageVo merchant) { + MchPaySettingVo setting = merchant.getPaySettingList().stream() + .filter(item -> ObjectUtil.equals(item.getPlatformType(), AppPlusPlatformPayType.ALI_PAY.getType())) + .filter(item -> ObjectUtil.equals(item.getEnable(), YesNoEnum.YES.getCode())) + .findFirst().orElse(null); + if (setting == null || StrUtil.isBlank(setting.getSettingJson())) { + throw new RuntimeException("商户支付宝支付配置未启用"); + } + MchPaySettingEntity.AliPaySetting detail = JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + if (detail == null || StrUtil.isBlank(detail.getAppAuthToken())) { + throw new RuntimeException("商户支付宝授权Token未配置"); + } + return setting; + } + + /** + * 选择已启用且包含 sub_mchid 的微信商户配置。 + * + * @param merchant 收款商户详情 + * @return 当前商户微信支付配置 + */ + private MchPaySettingVo getRequiredWechatPaySetting(MchManageVo merchant) { + MchPaySettingVo setting = merchant.getPaySettingList().stream() + .filter(item -> ObjectUtil.equals(item.getPlatformType(), AppPlusPlatformPayType.WECHAT.getType())) + .filter(item -> ObjectUtil.equals(item.getEnable(), YesNoEnum.YES.getCode())) + .findFirst().orElse(null); + if (setting == null || StrUtil.isBlank(setting.getSettingJson())) { + throw new RuntimeException("商户微信支付配置未启用"); + } + MchPaySettingEntity.WechatPaySetting detail = JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class); + if (detail == null || StrUtil.isBlank(detail.getSubMchid())) { + throw new RuntimeException("商户微信子商户号未配置"); + } + return setting; + } + + /** + * 校验支付宝和微信全局应用配置满足 OAuth、JSAPI 下单、回调与验签要求。 + */ + private void validateApplicationConfig() { + AppPlusPayConfig.AliPayConfig ali = appPlusPayConfig.getAliPayConfig(); + if (ali == null || StrUtil.hasBlank(ali.getAppId(), ali.getAppPrivateKey(), ali.getAlipayPublicKey(), + ali.getBaseCallbackUrl(), ali.getPayCallbackUrl())) { + throw new RuntimeException("支付宝应用支付配置不完整"); + } + AppPlusPayConfig.WechatConfig wechat = appPlusPayConfig.getWechatConfig(); + if (wechat == null || StrUtil.hasBlank(wechat.getGzhAppId(), wechat.getGzhAppSecret(), wechat.getMerchantId(), wechat.getMchPrivatePath(), + wechat.getMerchantSerialNumber(), wechat.getApiV3Key(), wechat.getBaseCallbackUrl(), wechat.getPayCallbackUrl())) { + throw new RuntimeException("微信服务商JSAPI配置不完整"); + } + } + + /** + * 根据 User-Agent 严格识别支付宝或微信扫码客户端。 + * + * @param userAgent HTTP User-Agent,允许为空 + * @return 支付宝或微信平台 + */ + private AppPlusPlatformTypeEnum resolveClientPlatform(String userAgent) { + String normalized = StrUtil.blankToDefault(userAgent, "").toLowerCase(Locale.ROOT); + if (normalized.contains("micromessenger")) { + return AppPlusPlatformTypeEnum.WECHAT; + } + if (normalized.contains("alipayclient")) { + return AppPlusPlatformTypeEnum.ALI_PAY; + } + throw new RuntimeException("该聚合二维码仅支持支付宝或微信扫码支付"); + } + + /** + * 解析租户唯一编码,公网入口不得直接信任客户端传入的租户ID。 + * + * @param tenantCode 租户唯一编码 + * @return 有效租户实体 + */ + private SysTenant resolveTenant(String tenantCode) { + if (StrUtil.isBlank(tenantCode)) { + throw new RuntimeException("租户编码不能为空"); + } + R> result = remoteTenantService.list(); + List tenants = result == null || !result.isOk() ? null : result.getData(); + if (ObjectUtil.isEmpty(tenants)) { + throw new RuntimeException("租户信息不可用"); + } + return tenants.stream().filter(tenant -> ObjectUtil.equals(tenant.getCode(), tenantCode)).findFirst() + .orElseThrow(() -> new RuntimeException("租户编码无效")); + } + + /** + * 按租户ID读取公网 URL 唯一编码。 + * + * @param tenantId 商户或会话所属租户ID + * @return 租户唯一编码 + */ + private String getTenantCode(Long tenantId) { + R> result = remoteTenantService.list(); + List tenants = result == null || !result.isOk() ? null : result.getData(); + if (ObjectUtil.isEmpty(tenants)) { + throw new RuntimeException("租户信息不可用"); + } + return tenants.stream().filter(tenant -> ObjectUtil.equals(tenant.getId(), tenantId)).map(SysTenant::getCode) + .filter(StrUtil::isNotBlank).findFirst().orElseThrow(() -> new RuntimeException("租户唯一编码未配置")); + } + + /** + * 构建聚合创建结果,路径固定使用 aggregate-order-pay 并与自有钱包 action 隔离。 + * + * @param session 聚合支付会话 + * @param accessToken 收银 URL 原始随机访问令牌 + * @param tenantCode 租户唯一编码 + * @return 聚合二维码创建结果 + */ + private ThirdAggregateOrderCodePayVo buildCreateVo(ThirdAggregatePaySessionEntity session, String accessToken, String tenantCode) { + ThirdAggregateOrderCodePayVo result = new ThirdAggregateOrderCodePayVo(); + result.setAggregateNo(session.getAggregateNo()); + result.setQrCode(buildPublicUrl(getPublicBaseUrl(), "/aggregate-order-pay/cashier/" + tenantCode + "/" + accessToken)); + result.setQrCodeType(QR_CODE_TYPE); + result.setExpireTime(session.getExpireTime()); + return result; + } + + /** + * 构建最小聚合状态 VO。 + * + * @param session 当前最新聚合会话 + * @return 不含配置、令牌和第三方原始报文的状态 + */ + private ThirdAggregateOrderCodePayStatusVo buildStatusVo(ThirdAggregatePaySessionEntity session) { + ThirdAggregateOrderCodePayStatusVo result = new ThirdAggregateOrderCodePayStatusVo(); + result.setAggregateNo(session.getAggregateNo()); + result.setStatus(session.getStatus()); + result.setSelectedPlatform(AppPlusPlatformTypeEnum.valueOfByType(session.getSelectedPlatform())); + result.setPayNo(session.getPayNo()); + result.setPaySuccess(ObjectUtil.equals(session.getStatus(), ThirdAggregatePaySessionStatusEnum.PAY_SUCCESS.getType())); + result.setExpireTime(session.getExpireTime()); + return result; + } + + /** + * 构建第三方子订单上下文,金额、商户和失效时间完全继承聚合会话。 + * + * @param session 已选定平台的聚合会话 + * @param payNo 预分配第三方商户订单号 + * @return 平台子订单创建上下文 + */ + private ThirdAggregatePayCreateContext buildCreateContext(ThirdAggregatePaySessionEntity session, String payNo) { + ThirdAggregatePayCreateContext context = new ThirdAggregatePayCreateContext(); + context.setPayNo(payNo); + context.setBizId(session.getBizId()); + context.setBizType(ThirdPayOrderBizTypeEnum.valueOfByType(session.getBizType())); + context.setUserId(session.getUserId()); + context.setMchId(session.getMchId()); + context.setAmount(session.getPrice()); + context.setExpireTime(session.getExpireTime()); + return context; + } + + /** + * 获取聚合二维码和状态查询共用的公网基础地址。 + * + * @return 去除末尾斜杠的支付宝现有回调基础地址 + */ + private String getPublicBaseUrl() { + return normalizeBaseUrl(appPlusPayConfig.getAliPayConfig().getBaseCallbackUrl()); + } + + /** + * 获取指定平台 OAuth 回调使用的现有公网基础地址。 + * + * @param platform 支付宝或微信平台 + * @return 对应现有回调基础地址 + */ + private String getPlatformBaseUrl(AppPlusPlatformTypeEnum platform) { + if (ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.WECHAT)) { + return normalizeBaseUrl(appPlusPayConfig.getWechatConfig().getBaseCallbackUrl()); + } + return getPublicBaseUrl(); + } + + /** + * 去除公网基础地址末尾斜杠,避免拼接路径时产生双斜杠。 + * + * @param baseUrl 配置中心中的公网基础地址 + * @return 规范化基础地址 + */ + private String normalizeBaseUrl(String baseUrl) { + if (StrUtil.isBlank(baseUrl)) { + throw new RuntimeException("支付公网回调地址未配置"); + } + String normalized = StrUtil.removeSuffix(baseUrl.trim(), "/"); + if (!normalized.toLowerCase(Locale.ROOT).startsWith("https://")) { + throw new RuntimeException("聚合支付公网地址必须使用HTTPS"); + } + return normalized; + } + + /** + * 拼接公网基础地址、thirdparty 网关前缀和以斜杠开头的固定服务路径。 + * + * @param baseUrl 公网基础地址 + * @param path 聚合支付固定路径 + * @return 完整 HTTPS 地址 + */ + private String buildPublicUrl(String baseUrl, String path) { + return normalizeBaseUrl(baseUrl) + PUBLIC_ROUTE_PREFIX + path; + } + + /** + * 获取 OAuth 回调路径中的平台固定片段。 + * + * @param platform 支付宝或微信平台 + * @return {@code alipay} 或 {@code wechat} + */ + private String platformPath(AppPlusPlatformTypeEnum platform) { + return ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.WECHAT) ? "wechat" : "alipay"; + } + + /** + * 使用 UTF-8 对 OAuth URL 查询参数进行百分号编码。 + * + * @param value 查询参数原值 + * @return URL 编码值 + */ + private String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + /** + * 生成指定字节数的 URL 安全随机令牌,不包含填充字符。 + * + * @param byteLength 随机字节数 + * @return URL 安全 Base64 字符串 + */ + private String createRandomToken(int byteLength) { + byte[] bytes = new byte[byteLength]; + secureRandom.nextBytes(bytes); + return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + /** + * 计算敏感标识的 SHA-256 十六进制摘要,数据库和 Redis 仅使用摘要比较。 + * + * @param value 原始访问令牌或付款人身份 + * @return 64位小写十六进制摘要 + */ + private String sha256(String value) { + if (StrUtil.isBlank(value)) { + throw new RuntimeException("安全标识不能为空"); + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("运行环境不支持SHA-256", exception); + } + } + + /** + * 解密幂等复用所需的聚合访问令牌,失败时不回显密文。 + * + * @param session 待复用聚合会话 + * @return 原始随机访问令牌 + */ + private String decryptAccessToken(ThirdAggregatePaySessionEntity session) { + try { + return stringEncryptor.decrypt(session.getAccessTokenCiphertext()); + } catch (RuntimeException exception) { + throw new RuntimeException("聚合二维码访问令牌无法恢复,请重新创建"); + } + } + + /** + * 生成长度满足支付宝和微信商户订单号限制的聚合会话编号。 + * + * @return 以 AGG 开头的唯一会话编号 + */ + private String createAggregateNo() { + return "AGG" + LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + + RandomUtil.randomString(8); + } + + /** + * 生成长度满足支付宝和微信限制的第三方商户订单号。 + * + * @return 以 AGP 开头的唯一子订单号 + */ + private String createPayNo() { + return "AGP" + LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + + RandomUtil.randomString(10); + } + + /** + * 使用令牌校验脚本释放创建锁,Redis 异常不覆盖主业务结果。 + * + * @param lockKey 当前请求锁键 + * @param lockToken 当前请求锁令牌 + */ + private void releaseLock(String lockKey, String lockToken) { + try { + stringRedisTemplate.execute(UNLOCK_SCRIPT, List.of(lockKey), lockToken); + } catch (Exception exception) { + log.warn("|{}|聚合支付创建锁释放异常", TAG); + } + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImpl.java new file mode 100644 index 000000000..4c9a38283 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImpl.java @@ -0,0 +1,369 @@ +package com.bsd.pjcloud.thirdparty.service.impl.appplus; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateWechatPayService; +import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import com.wechat.pay.java.core.exception.ServiceException; +import com.wechat.pay.java.service.partnerpayments.jsapi.JsapiServiceExtension; +import com.wechat.pay.java.service.partnerpayments.jsapi.model.Amount; +import com.wechat.pay.java.service.partnerpayments.jsapi.model.CloseOrderRequest; +import com.wechat.pay.java.service.partnerpayments.jsapi.model.Payer; +import com.wechat.pay.java.service.partnerpayments.jsapi.model.PrepayRequest; +import com.wechat.pay.java.service.partnerpayments.jsapi.model.PrepayWithRequestPaymentResponse; +import com.wechat.pay.java.service.partnerpayments.jsapi.model.QueryOrderByOutTradeNoRequest; +import com.wechat.pay.java.service.partnerpayments.jsapi.model.Transaction; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 微信支付服务商聚合订单码 OAuth 与 JSAPI 支付实现。 + */ +@Service +@Validated +@Slf4j +@RequiredArgsConstructor +public class ThirdAggregateWechatPayServiceImpl implements ThirdAggregateWechatPayService { + + /** + * 微信聚合支付日志标签,不包含公众号 Secret、OAuth code 或 sp_openid。 + */ + private static final String TAG = "微信服务商聚合JSAPI支付"; + + /** + * 微信公众号网页授权码换取 access_token 的官方接口地址。 + */ + private static final String OAUTH_TOKEN_URL = "https://api.weixin.qq.com/sns/oauth2/access_token"; + + /** + * 微信网页授权固定 grant_type。 + */ + private static final String OAUTH_GRANT_TYPE = "authorization_code"; + + /** + * 微信 OAuth HTTP 请求连接与读取总超时,单位毫秒。 + */ + private static final int OAUTH_TIMEOUT_MILLIS = 5000; + + /** + * 微信账单固定商品描述,避免传递内部订单信息。 + */ + private static final String DESCRIPTION = "资安信消费支付"; + + /** + * 微信支付人民币币种编码。 + */ + private static final String CURRENCY_CNY = "CNY"; + + /** + * 微信服务商 JSAPI SDK 服务,复用现有服务商证书配置。 + */ + private final JsapiServiceExtension aggregateWechatJsapiServiceExtension; + + /** + * 微信服务商应用和回调地址配置,来源于现有配置中心。 + */ + private final AppPlusPayConfig appPlusPayConfig; + + /** + * 第三方支付订单服务,用于保存 JSAPI 子订单和精简平台响应。 + */ + private final ThirdPayOrderService thirdPayOrderService; + + /** + * 调用服务商统一公众号 OAuth 接口换取 sp_openid。 + * + * @param authCode 微信公众号 OAuth 回调授权码 + * @return 当前服务商公众号下的付款人 sp_openid + */ + @Override + public String exchangeSpOpenId(String authCode) { + AppPlusPayConfig.WechatConfig wechatConfig = appPlusPayConfig.getWechatConfig(); + if (StrUtil.isBlank(authCode) || wechatConfig == null + || StrUtil.hasBlank(wechatConfig.getGzhAppId(), wechatConfig.getGzhAppSecret())) { + throw new RuntimeException("微信用户授权参数不完整"); + } + Map form = new LinkedHashMap<>(); + form.put("appid", wechatConfig.getGzhAppId()); + form.put("secret", wechatConfig.getGzhAppSecret()); + form.put("code", authCode); + form.put("grant_type", OAUTH_GRANT_TYPE); + try (HttpResponse response = HttpRequest.get(OAUTH_TOKEN_URL) + .timeout(OAUTH_TIMEOUT_MILLIS) + .form(form) + .execute()) { + if (!response.isOk()) { + log.warn("|{}|微信用户授权HTTP失败,httpStatus:{}", TAG, response.getStatus()); + throw new RuntimeException("微信用户授权失败,请重新扫码"); + } + JSONObject body = JSONUtil.parseObj(response.body()); + String spOpenId = body.getStr("openid"); + if (StrUtil.isBlank(spOpenId)) { + log.warn("|{}|微信用户授权业务失败,errorCode:{}", TAG, body.getStr("errcode")); + throw new RuntimeException("微信用户授权失败,请重新扫码"); + } + return spOpenId; + } catch (RuntimeException exception) { + throw exception; + } catch (Exception exception) { + log.warn("|{}|微信用户授权异常,exceptionType:{}", TAG, exception.getClass().getSimpleName()); + throw new RuntimeException("微信用户授权失败,请重新扫码"); + } + } + + /** + * 创建服务商 JSAPI 子订单,使用统一服务商公众号 sp_appid 与 sp_openid 标识付款人。 + * + * @param context 已绑定聚合会话的支付上下文 + * @param paySetting 当前商户启用的微信子商户号配置 + * @param spOpenId 当前服务商统一公众号下的付款人 openid + * @return 微信 JSBridge 调起签名参数 + */ + @Override + public ThirdAggregateCashierLaunch createPay(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting, + String spOpenId) { + MchPaySettingEntity.WechatPaySetting wechatPaySetting = parseWechatPaySetting(paySetting); + AppPlusPayConfig.WechatConfig wechatConfig = appPlusPayConfig.getWechatConfig(); + ThirdPayOrderEntity payOrder = buildPayOrder(context, paySetting); + + Amount amount = new Amount(); + amount.setTotal(context.getAmount()); + amount.setCurrency(CURRENCY_CNY); + Payer payer = new Payer(); + payer.setSpOpenid(spOpenId); + + PrepayRequest request = new PrepayRequest(); + request.setSpAppid(wechatConfig.getGzhAppId()); + request.setSpMchid(wechatConfig.getMerchantId()); + request.setSubMchid(wechatPaySetting.getSubMchid()); + request.setDescription(DESCRIPTION); + request.setOutTradeNo(context.getPayNo()); + request.setTimeExpire(formatWechatTime(context.getExpireTime())); + request.setNotifyUrl(wechatConfig.getBaseCallbackUrl() + wechatConfig.getPayCallbackUrl()); + request.setAmount(amount); + request.setPayer(payer); + payOrder.setPayRep(buildRequestSnapshot(context, wechatPaySetting).toString()); + + PrepayWithRequestPaymentResponse response; + try { + response = aggregateWechatJsapiServiceExtension.prepayWithRequestPayment(request, wechatConfig.getGzhAppId()); + } catch (ServiceException exception) { + String errorCode = StrUtil.blankToDefault(exception.getErrorCode(), "UNKNOWN"); + saveFailedOrder(payOrder, errorCode, exception.getClass().getSimpleName()); + log.warn("|{}|微信JSAPI子订单创建失败,payNo:{},errorCode:{}", TAG, context.getPayNo(), errorCode); + throw new RuntimeException("创建微信支付订单失败,请重新扫码"); + } catch (Exception exception) { + saveFailedOrder(payOrder, "SDK_EXCEPTION", exception.getClass().getSimpleName()); + log.warn("|{}|微信JSAPI子订单创建异常,payNo:{},exceptionType:{}", TAG, context.getPayNo(), exception.getClass().getSimpleName()); + throw new RuntimeException("创建微信支付订单失败,请重新扫码"); + } + if (!isCompleteResponse(response)) { + saveFailedOrder(payOrder, "INCOMPLETE_RESPONSE", null); + throw new RuntimeException("创建微信支付订单失败,请重新扫码"); + } + + Map payParams = new LinkedHashMap<>(); + payParams.put("appId", response.getAppId()); + payParams.put("timeStamp", response.getTimeStamp()); + payParams.put("nonceStr", response.getNonceStr()); + payParams.put("package", response.getPackageVal()); + payParams.put("signType", response.getSignType()); + payParams.put("paySign", response.getPaySign()); + payOrder.setPayRes(JSONUtil.createObj().set("aggregatePay", true).set("launchParams", payParams).toString()); + try { + if (!thirdPayOrderService.save(payOrder)) { + throw new RuntimeException("聚合支付子订单保存失败"); + } + } catch (RuntimeException exception) { + close(context.getPayNo(), JSONUtil.toJsonStr(paySetting)); + log.error("|{}|微信JSAPI子订单保存失败,已执行最佳努力关单,payNo:{}", TAG, context.getPayNo()); + throw new RuntimeException("创建微信支付订单失败,请重新扫码"); + } + return new ThirdAggregateCashierLaunch(AppPlusPlatformTypeEnum.WECHAT, context.getPayNo(), payParams); + } + + /** + * 使用订单配置快照中的子商户号查询微信服务商 JSAPI 子订单。 + * + * @param payNo 第三方商户订单号 + * @param settingJson 创建订单时保存的微信子商户配置快照 + * @return 微信订单状态;调用异常时返回 {@code null} + */ + @Override + public Transaction query(String payNo, String settingJson) { + try { + MchPaySettingEntity.WechatPaySetting setting = parseWechatPaySettingSnapshot(settingJson); + QueryOrderByOutTradeNoRequest request = new QueryOrderByOutTradeNoRequest(); + request.setOutTradeNo(payNo); + request.setSpMchid(appPlusPayConfig.getWechatConfig().getMerchantId()); + request.setSubMchid(setting.getSubMchid()); + return aggregateWechatJsapiServiceExtension.queryOrderByOutTradeNo(request); + } catch (ServiceException exception) { + log.warn("|{}|查询微信JSAPI子订单失败,payNo:{},errorCode:{}", TAG, payNo, + StrUtil.blankToDefault(exception.getErrorCode(), "UNKNOWN")); + } catch (Exception exception) { + log.warn("|{}|查询微信JSAPI子订单异常,payNo:{},exceptionType:{}", TAG, payNo, exception.getClass().getSimpleName()); + } + return null; + } + + /** + * 使用订单配置快照中的子商户号关闭微信服务商 JSAPI 子订单。 + * + * @param payNo 第三方商户订单号 + * @param settingJson 创建订单时保存的微信子商户配置快照 + * @return SDK 无异常完成关单时返回 {@code true} + */ + @Override + public boolean close(String payNo, String settingJson) { + try { + MchPaySettingEntity.WechatPaySetting setting = parseWechatPaySettingSnapshot(settingJson); + CloseOrderRequest request = new CloseOrderRequest(); + request.setOutTradeNo(payNo); + request.setSpMchid(appPlusPayConfig.getWechatConfig().getMerchantId()); + request.setSubMchid(setting.getSubMchid()); + aggregateWechatJsapiServiceExtension.closeOrder(request); + return true; + } catch (ServiceException exception) { + log.warn("|{}|关闭微信JSAPI子订单失败,payNo:{},errorCode:{}", TAG, payNo, + StrUtil.blankToDefault(exception.getErrorCode(), "UNKNOWN")); + } catch (Exception exception) { + log.warn("|{}|关闭微信JSAPI子订单异常,payNo:{},exceptionType:{}", TAG, payNo, exception.getClass().getSimpleName()); + } + return false; + } + + /** + * 构建不含 sp_openid 的微信 JSAPI 请求快照。 + * + * @param context 聚合支付子订单上下文 + * @param paySetting 微信支付子商户号配置 + * @return 可安全持久化的精简请求信息 + */ + private JSONObject buildRequestSnapshot(ThirdAggregatePayCreateContext context, + MchPaySettingEntity.WechatPaySetting paySetting) { + return JSONUtil.createObj() + .set("aggregatePay", true) + .set("tradeType", "JSAPI") + .set("outTradeNo", context.getPayNo()) + .set("spAppid", appPlusPayConfig.getWechatConfig().getGzhAppId()) + .set("subMchid", paySetting.getSubMchid()) + .set("amount", context.getAmount()) + .set("expireTime", context.getExpireTime()); + } + + /** + * 构建待支付的微信聚合 JSAPI 子订单。 + * + * @param context 聚合支付子订单上下文 + * @param paySetting 商户微信配置快照 + * @return 可持久化的未支付订单 + */ + private ThirdPayOrderEntity buildPayOrder(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting) { + ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity(); + payOrder.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType()); + payOrder.setBizType(context.getBizType().getType()); + payOrder.setBizId(context.getBizId()); + payOrder.setUserId(context.getUserId()); + payOrder.setMchId(context.getMchId()); + payOrder.setPrice(context.getAmount()); + payOrder.setPayNo(context.getPayNo()); + payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType()); + payOrder.setExpireTime(context.getExpireTime()); + payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(paySetting)); + return payOrder; + } + + /** + * 保存微信 JSAPI 子订单创建失败的精简状态。 + * + * @param payOrder 待保存的第三方支付订单 + * @param errorCode 微信错误码或本地错误标识 + * @param exceptionType 异常类型;非异常失败时为空 + */ + private void saveFailedOrder(ThirdPayOrderEntity payOrder, String errorCode, String exceptionType) { + JSONObject snapshot = JSONUtil.createObj().set("aggregatePay", true).set("code", errorCode); + if (StrUtil.isNotBlank(exceptionType)) { + snapshot.set("exceptionType", exceptionType); + } + payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_FAILED.getType()); + payOrder.setPayRes(snapshot.toString()); + thirdPayOrderService.save(payOrder); + } + + /** + * 校验并解析当前商户微信子商户号配置。 + * + * @param paySetting 当前商户启用的微信支付配置 + * @return 包含非空 sub_mchid 的配置 + */ + private MchPaySettingEntity.WechatPaySetting parseWechatPaySetting(MchPaySettingVo paySetting) { + if (paySetting == null || StrUtil.isBlank(paySetting.getSettingJson())) { + throw new RuntimeException("商户微信支付配置不完整"); + } + MchPaySettingEntity.WechatPaySetting setting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class); + if (setting == null || StrUtil.isBlank(setting.getSubMchid())) { + throw new RuntimeException("商户微信子商户号未配置"); + } + return setting; + } + + /** + * 从第三方支付订单的商户配置快照解析微信子商户号。 + * + * @param settingJson 创建订单时保存的商户配置快照 + * @return 包含非空 sub_mchid 的微信配置 + */ + private MchPaySettingEntity.WechatPaySetting parseWechatPaySettingSnapshot(String settingJson) { + if (StrUtil.isBlank(settingJson)) { + throw new RuntimeException("支付订单缺少商户配置快照"); + } + MchPaySettingEntity snapshot = JSONUtil.toBean(settingJson, MchPaySettingEntity.class); + if (snapshot == null || StrUtil.isBlank(snapshot.getSettingJson())) { + throw new RuntimeException("支付订单商户配置快照无效"); + } + MchPaySettingVo setting = new MchPaySettingVo(); + setting.setSettingJson(snapshot.getSettingJson()); + return parseWechatPaySetting(setting); + } + + /** + * 判断微信 SDK 返回的 JSBridge 签名参数是否完整。 + * + * @param response 微信服务商 JSAPI 预支付响应 + * @return 所有必需字段非空时返回 {@code true} + */ + private boolean isCompleteResponse(PrepayWithRequestPaymentResponse response) { + return response != null && !StrUtil.hasBlank(response.getAppId(), response.getTimeStamp(), response.getNonceStr(), + response.getPackageVal(), response.getSignType(), response.getPaySign()); + } + + /** + * 将服务端本地时间转换为微信支付要求的 RFC3339 带时区时间。 + * + * @param expireTime 服务端支付截止时间 + * @return 精确到秒的 RFC3339 字符串 + */ + private String formatWechatTime(LocalDateTime expireTime) { + return expireTime.withNano(0).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + } +} 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 5439b5765..623e440f8 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 @@ -4,35 +4,47 @@ import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.date.DatePattern; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; +import com.alipay.api.AlipayApiException; import com.alipay.api.AlipayClient; import com.alipay.api.AlipayConfig; import com.alipay.api.DefaultAlipayClient; import com.alipay.api.domain.AlipayTradeAppPayModel; +import com.alipay.api.domain.AlipayTradeCancelModel; +import com.alipay.api.domain.AlipayTradePrecreateModel; import com.alipay.api.domain.AlipayTradeQueryModel; import com.alipay.api.domain.AlipayTradeRefundModel; -import com.alipay.api.domain.ExtendParams; import com.alipay.api.internal.util.AlipaySignature; import com.alipay.api.request.AlipayTradeAppPayRequest; +import com.alipay.api.request.AlipayTradeCancelRequest; +import com.alipay.api.request.AlipayTradePrecreateRequest; import com.alipay.api.request.AlipayTradeQueryRequest; import com.alipay.api.request.AlipayTradeRefundRequest; import com.alipay.api.response.AlipayTradeAppPayResponse; +import com.alipay.api.response.AlipayTradeCancelResponse; +import com.alipay.api.response.AlipayTradePrecreateResponse; import com.alipay.api.response.AlipayTradeQueryResponse; import com.alipay.api.response.AlipayTradeRefundResponse; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto; import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdMiniWechatRefundDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService; import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.alipay.ThirdALiPayCallbackVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; @@ -51,14 +63,54 @@ import java.util.Map; @RequiredArgsConstructor public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService { - private final String TAG = "支付宝支付(app版本)"; + /** + * 支付宝支付日志标签,仅用于标识模块,不包含商户和授权信息。 + */ + private static final String TAG = "支付宝支付(app版本)"; - static private AlipayClient alipayClient = null; + /** + * 支付宝订单码支付固定产品码,当面付预创建接口要求使用该值。 + */ + private static final String ORDER_CODE_PRODUCT_CODE = "QR_CODE_OFFLINE"; + /** + * 订单码交易标题,固定安全文案避免把业务隐私传给第三方平台。 + */ + private static final String ORDER_CODE_SUBJECT = "资安信消费支付"; + + /** + * 支付宝服务商模式传递商户授权令牌的公共参数名。 + */ + private static final String APP_AUTH_TOKEN_PARAM = "app_auth_token"; + + /** + * 当前应用配置对应的支付宝客户端;延迟初始化后由所有支付请求复用。 + */ + private volatile AlipayClient alipayClient; + + /** + * 支付宝应用级配置,来源于现有 app-plus.pay 配置中心。 + */ private final AppPlusPayConfig appPlusPayConfig; + + /** + * 第三方支付订单服务,用于保存请求快照并执行带状态条件的原子更新。 + */ private final ThirdPayOrderService thirdPayOrderService; + /** + * 聚合会话服务只用于共享回调确认 JSAPI 子订单具有严格的 pay_no 会话绑定。 + * + *

设为可选注入以保持现有测试子类构造器兼容;生产 Spring 容器会提供该 Bean。

+ */ + @Autowired(required = false) + private ThirdAggregatePaySessionService aggregatePaySessionService; + /** + * 构建支付宝 SDK 客户端配置,不输出或复制任何密钥到日志。 + * + * @return 当前应用的支付宝 SDK 配置 + */ private AlipayConfig getAlipayConfig() { AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.getAliPayConfig(); AlipayConfig alipayConfig = new AlipayConfig(); @@ -72,47 +124,144 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService return alipayConfig; } - private synchronized AlipayClient getAlipayClient() { + /** + * 获取延迟初始化的支付宝客户端。 + * + *

方法保留为 protected,便于单元测试替换为 Mock 客户端;生产环境只会创建一次。

+ * + * @return 可用的支付宝客户端 + */ + protected synchronized AlipayClient getAlipayClient() { if (alipayClient == null) { try { alipayClient = new DefaultAlipayClient(getAlipayConfig()); } catch (Exception e) { - e.printStackTrace(); - log.error("初始化支付宝客户端失败error:{}", e.getMessage()); + log.error("初始化支付宝客户端失败", e); + throw new RuntimeException("支付宝客户端初始化失败"); } } return alipayClient; } + /** + * 将人民币分精确转换为保留两位小数的人民币元字符串。 + * + * @param fen 金额,单位为人民币分 + * @return 两位小数的人民币元字符串 + */ private String fenToYuan(BigDecimal fen) { if (ObjectUtil.isEmpty(fen)) { - return "0"; + return "0.00"; } - return fen.divide(new BigDecimal(100), 2, RoundingMode.HALF_UP).toString(); + return fen.movePointLeft(2).setScale(2, RoundingMode.UNNECESSARY).toPlainString(); } /** - * 查询订单 + * 按商户订单号查询支付宝交易状态。 + * + * @param payNo 第三方支付服务生成的商户订单号 + * @param settingJson 创建订单时保存的商户配置快照 + * @return 支付宝查询响应;解析、网络或 SDK 异常时返回 {@code null} */ @Override public AlipayTradeQueryResponse query(String payNo, String settingJson) { - MchPaySettingEntity mchPaySettingEntity = JSONUtil.toBean(settingJson, MchPaySettingEntity.class); - MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(mchPaySettingEntity.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - - AlipayTradeQueryRequest request = new AlipayTradeQueryRequest(); - AlipayTradeQueryModel model = new AlipayTradeQueryModel(); - model.setOutTradeNo(payNo); - request.setBizModel(model); - request.putOtherTextParam("app_auth_token", aliPaySetting.getAppAuthToken()); try { + MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySettingSnapshot(settingJson); + AlipayTradeQueryRequest request = new AlipayTradeQueryRequest(); + AlipayTradeQueryModel model = new AlipayTradeQueryModel(); + model.setOutTradeNo(payNo); + request.setBizModel(model); + request.putOtherTextParam(APP_AUTH_TOKEN_PARAM, aliPaySetting.getAppAuthToken()); return getAlipayClient().execute(request); } catch (Exception e) { - e.printStackTrace(); - log.error("|{}|查询订单失败error:{}", TAG, e.getMessage()); + log.error("|{}|查询支付宝订单失败,payNo:{},exceptionType:{}", TAG, payNo, e.getClass().getSimpleName()); } return null; } + /** + * 调用支付宝当面付预创建接口并保存订单及不含授权令牌的请求、响应快照。 + * + * @param dto 已生成支付订单号的内部支付参数 + * @param setting 当前商户启用的支付宝支付配置 + * @param expireTime 服务端二维码失效时间 + * @return 支付订单号、二维码内容串和服务端过期时间 + */ + @Override + public ThirdOrderCodePayVo precreate(ThirdAppPlusPayDto dto, MchPaySettingVo setting, LocalDateTime expireTime) { + AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.getAliPayConfig(); + MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySetting(setting); + + ThirdPayOrderEntity payOrder = buildOrderCodePayOrder(dto, setting, expireTime); + AlipayTradePrecreateRequest request = new AlipayTradePrecreateRequest(); + request.setNotifyUrl(aliPayConfig.getBaseCallbackUrl() + aliPayConfig.getPayCallbackUrl()); + + AlipayTradePrecreateModel model = new AlipayTradePrecreateModel(); + model.setOutTradeNo(dto.getOutTradeNo()); + model.setTotalAmount(fenToYuan(BigDecimal.valueOf(dto.getAmount()))); + model.setSubject(ORDER_CODE_SUBJECT); + model.setProductCode(ORDER_CODE_PRODUCT_CODE); + model.setTimeExpire(LocalDateTimeUtil.format(expireTime, DatePattern.NORM_DATETIME_PATTERN)); + payOrder.setPayRep(JSONUtil.toJsonStr(model)); + + request.setBizModel(model); + request.putOtherTextParam(APP_AUTH_TOKEN_PARAM, aliPaySetting.getAppAuthToken()); + + AlipayTradePrecreateResponse response; + try { + response = getAlipayClient().execute(request); + } catch (Exception exception) { + payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_FAILED.getType()); + payOrder.setPayRes(buildExceptionSnapshot(exception).toString()); + thirdPayOrderService.save(payOrder); + log.error("|{}|支付宝订单码预创建异常,payNo:{},exceptionType:{}", TAG, dto.getOutTradeNo(), exception.getClass().getSimpleName()); + throw new RuntimeException("创建支付宝订单码失败,请稍后重试"); + } + + payOrder.setPayRes(buildPrecreateResponseSnapshot(response).toString()); + if (response == null || !response.isSuccess()) { + payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_FAILED.getType()); + thirdPayOrderService.save(payOrder); + String errorCode = response == null ? "EMPTY_RESPONSE" : StrUtil.firstNonBlank(response.getSubCode(), response.getCode(), "UNKNOWN"); + throw new RuntimeException("创建支付宝订单码失败,支付宝错误码:" + errorCode); + } + if (StrUtil.isBlank(response.getQrCode())) { + payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_FAILED.getType()); + thirdPayOrderService.save(payOrder); + throw new RuntimeException("创建支付宝订单码失败,支付宝未返回二维码"); + } + + thirdPayOrderService.save(payOrder); + ThirdOrderCodePayVo result = new ThirdOrderCodePayVo(); + result.setPayNo(dto.getOutTradeNo()); + result.setQrCode(response.getQrCode()); + result.setExpireTime(expireTime); + return result; + } + + /** + * 调用支付宝撤销接口;异常时保留未支付状态,由上层定时任务继续重试。 + * + * @param payNo 第三方支付服务生成的商户订单号 + * @param settingJson 创建订单时保存的商户支付配置快照 + * @return 支付宝撤销响应,调用异常时返回 {@code null} + */ + @Override + public AlipayTradeCancelResponse cancel(String payNo, String settingJson) { + try { + MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySettingSnapshot(settingJson); + AlipayTradeCancelRequest request = new AlipayTradeCancelRequest(); + AlipayTradeCancelModel model = new AlipayTradeCancelModel(); + model.setOutTradeNo(payNo); + request.setBizModel(model); + request.putOtherTextParam(APP_AUTH_TOKEN_PARAM, aliPaySetting.getAppAuthToken()); + return getAlipayClient().execute(request); + } catch (Exception exception) { + log.error("|{}|撤销支付宝订单失败,payNo:{},exceptionType:{}", TAG, payNo, exception.getClass().getSimpleName()); + return null; + } + } + /** * app支付 */ @@ -156,8 +305,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService payOrder.setPayRes(orderStr); return orderStr; } catch (Exception e) { - e.printStackTrace(); - log.error("|{}|app支付异常,error:|{}|", TAG, e.getMessage()); + log.error("|{}|app支付异常,payNo:{},exceptionType:{}", TAG, dto.getOutTradeNo(), e.getClass().getSimpleName()); return null; } finally { thirdPayOrderService.save(payOrder); @@ -216,8 +364,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } } } catch (Exception e) { - e.printStackTrace(); - log.error("|{}|app退款异常,error:|{}|", TAG, e.getMessage()); + log.error("|{}|app退款异常,payNo:{},exceptionType:{}", TAG, thirdPayOrder.getPayNo(), e.getClass().getSimpleName()); } finally { thirdPayOrderService.updateById(updateObj); } @@ -225,34 +372,77 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } /** - * 支付回调 + * 验证支付宝回调签名、应用、订单、平台和金额,并原子推进支付终态。 + * + * @param params 支付宝异步通知表单参数 + * @return 验真通过后的状态结果;非法通知返回 {@code null} */ @Override - public ThirdPayOrderEntity payCallback(Map params) { - String paramsJson = JSONUtil.toJsonStr(params); - ThirdALiPayCallbackVo vo = JSONUtil.toBean(paramsJson, ThirdALiPayCallbackVo.class); - if (ObjectUtil.isEmpty(vo)) { + public AliPayCallbackResult payCallback(Map params) { + if (ObjectUtil.isEmpty(params) || !verifyPayCallbackSignature(params)) { return null; } - boolean paySucess = vo.isPaySucess(); - // 查询订单 - ThirdPayOrderEntity payOrderEntity = thirdPayOrderService.getByPayNo(vo.getOut_trade_no()); + + String payNo = params.get("out_trade_no"); + ThirdPayOrderEntity payOrderEntity = thirdPayOrderService.getByPayNo(payNo); if (ObjectUtil.isEmpty(payOrderEntity)) { - log.error("|{}|payCallback,意外的错误,订单不存在,微信返回json|{}|", TAG, paramsJson); + log.warn("|{}|支付宝支付回调订单不存在,payNo:{}", TAG, payNo); return null; } - // 重复的回调 + + if (!validatePayCallbackOrder(params, payOrderEntity)) { + return null; + } + + String tradeStatus = params.get("trade_status"); + boolean paySuccess = ObjectUtil.equals(tradeStatus, "TRADE_SUCCESS") || ObjectUtil.equals(tradeStatus, "TRADE_FINISHED"); + boolean payClosed = ObjectUtil.equals(tradeStatus, "TRADE_CLOSED"); + boolean waitingBuyerPay = ObjectUtil.equals(tradeStatus, "WAIT_BUYER_PAY"); + if (!paySuccess && !payClosed && !waitingBuyerPay) { + log.warn("|{}|支付宝支付回调状态不可处理,payNo:{},tradeStatus:{}", TAG, payNo, tradeStatus); + return null; + } + + if (waitingBuyerPay) { + return new AliPayCallbackResult(payOrderEntity, + ObjectUtil.equals(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()), false); + } + + // 终态通知已经处理过时直接确认,避免支付宝重试导致业务成功逻辑重复执行。 + if (ObjectUtil.equals(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) { + return new AliPayCallbackResult(payOrderEntity, true, false); + } + if (ObjectUtil.equals(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.PAY_CLOSED.getType())) { + return new AliPayCallbackResult(payOrderEntity, false, false); + } if (ObjectUtil.notEqual(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) { - log.error("|{}|payCallback,意外的错误,重复的回调,微信返回json|{}|", TAG, paramsJson); return null; } - // 更新订单状态 + ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity(); - updateObj.setId(payOrderEntity.getId()); - updateObj.setPayCallbackRes(paramsJson); - updateObj.setStatus(paySucess ? ThirdPayOrderStatusEnum.PAY_SUCCESS.getType() : ThirdPayOrderStatusEnum.PAY_FAILED.getType()); - thirdPayOrderService.updateById(updateObj); - return thirdPayOrderService.getById(payOrderEntity.getId()); + updateObj.setPayCallbackRes(JSONUtil.toJsonStr(params)); + updateObj.setStatus(paySuccess ? ThirdPayOrderStatusEnum.PAY_SUCCESS.getType() : ThirdPayOrderStatusEnum.PAY_CLOSED.getType()); + if (paySuccess) { + updateObj.setThirdPayNo(params.get("trade_no")); + updateObj.setPayTime(LocalDateTime.now()); + } + boolean stateChanged = thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, payOrderEntity.getId()) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType())); + ThirdPayOrderEntity latestOrder = thirdPayOrderService.getById(payOrderEntity.getId()); + if (latestOrder == null) { + return null; + } + if (!stateChanged) { + if (ObjectUtil.equals(latestOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) { + return new AliPayCallbackResult(latestOrder, true, false); + } + if (ObjectUtil.equals(latestOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_CLOSED.getType())) { + return new AliPayCallbackResult(latestOrder, false, false); + } + return null; + } + return new AliPayCallbackResult(latestOrder, paySuccess, true); } /** @@ -318,4 +508,190 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService return null; } + /** + * 构建订单码支付的待支付订单,完整记录业务、平台、金额、配置和过期时间。 + * + * @param dto 内部支付参数 + * @param setting 商户支付宝配置 + * @param expireTime 服务端二维码失效时间 + * @return 可直接持久化的待支付订单 + */ + private ThirdPayOrderEntity buildOrderCodePayOrder(ThirdAppPlusPayDto dto, MchPaySettingVo setting, LocalDateTime expireTime) { + ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity(); + payOrder.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType()); + payOrder.setBizId(dto.getBizId()); + payOrder.setBizType(dto.getBizType().getType()); + payOrder.setUserId(dto.getUserId()); + payOrder.setPrice(dto.getAmount()); + payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType()); + payOrder.setPayNo(dto.getOutTradeNo()); + payOrder.setMchId(dto.getMchId()); + payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting)); + payOrder.setExpireTime(expireTime); + return payOrder; + } + + /** + * 解析当前商户支付宝配置并确认授权令牌存在。 + * + * @param setting 当前商户支付配置 + * @return 支付宝商户授权配置 + */ + private MchPaySettingEntity.AliPaySetting parseAliPaySetting(MchPaySettingVo setting) { + if (setting == null || StrUtil.isBlank(setting.getSettingJson())) { + throw new RuntimeException("商户支付宝支付配置不完整"); + } + MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + if (aliPaySetting == null || StrUtil.isBlank(aliPaySetting.getAppAuthToken())) { + throw new RuntimeException("商户支付宝授权Token未配置"); + } + return aliPaySetting; + } + + /** + * 从支付订单保存的商户配置快照中解析支付宝授权配置。 + * + * @param settingJson 创建支付订单时保存的配置快照 + * @return 支付宝商户授权配置 + */ + private MchPaySettingEntity.AliPaySetting parseAliPaySettingSnapshot(String settingJson) { + if (StrUtil.isBlank(settingJson)) { + throw new RuntimeException("支付订单缺少商户配置快照"); + } + MchPaySettingEntity paySetting = JSONUtil.toBean(settingJson, MchPaySettingEntity.class); + if (paySetting == null) { + throw new RuntimeException("支付订单商户配置快照无效"); + } + MchPaySettingVo setting = BeanUtil.toBean(paySetting, MchPaySettingVo.class); + return parseAliPaySetting(setting); + } + + /** + * 生成支付宝预创建响应快照,仅保存定位订单所需字段和二维码内容。 + * + * @param response 支付宝 SDK 响应,允许为空 + * @return 可持久化的精简响应快照 + */ + private JSONObject buildPrecreateResponseSnapshot(AlipayTradePrecreateResponse response) { + JSONObject snapshot = JSONUtil.createObj(); + if (response == null) { + snapshot.set("code", "EMPTY_RESPONSE"); + return snapshot; + } + snapshot.set("code", response.getCode()); + snapshot.set("message", response.getMsg()); + snapshot.set("subCode", response.getSubCode()); + snapshot.set("subMessage", response.getSubMsg()); + snapshot.set("outTradeNo", response.getOutTradeNo()); + snapshot.set("qrCode", response.getQrCode()); + return snapshot; + } + + /** + * 生成支付宝 SDK 异常快照,避免把堆栈、密钥或授权令牌持久化到订单。 + * + * @param exception SDK 或网络调用异常 + * @return 仅包含错误类别和 SDK 错误码的精简快照 + */ + private JSONObject buildExceptionSnapshot(Exception exception) { + JSONObject snapshot = JSONUtil.createObj(); + snapshot.set("code", "SDK_EXCEPTION"); + snapshot.set("exceptionType", exception.getClass().getSimpleName()); + if (exception instanceof AlipayApiException alipayApiException) { + snapshot.set("alipayErrorCode", alipayApiException.getErrCode()); + } + return snapshot; + } + + /** + * 使用应用支付宝公钥对支付回调执行 RSA2 验签。 + * + * @param params 支付宝表单回调参数 + * @return 验签通过时返回 {@code true} + */ + protected boolean verifyPayCallbackSignature(Map params) { + try { + return AlipaySignature.rsaCheckV1(params, appPlusPayConfig.getAliPayConfig().getAlipayPublicKey(), "UTF-8", "RSA2"); + } catch (Exception exception) { + log.warn("|{}|支付宝支付回调验签异常,payNo:{}", TAG, params.get("out_trade_no")); + return false; + } + } + + /** + * 校验回调应用、商户订单、支付平台及金额均与内部订单一致。 + * + * @param params 已通过签名校验的支付宝回调参数 + * @param payOrder 回调商户订单号对应的内部支付订单 + * @return 全部业务约束一致时返回 {@code true} + */ + private boolean validatePayCallbackOrder(Map params, ThirdPayOrderEntity payOrder) { + String payNo = payOrder.getPayNo(); + if (ObjectUtil.notEqual(params.get("app_id"), appPlusPayConfig.getAliPayConfig().getAppId())) { + log.warn("|{}|支付宝支付回调appId不匹配,payNo:{}", TAG, payNo); + return false; + } + if (ObjectUtil.notEqual(params.get("out_trade_no"), payNo) + || ObjectUtil.notEqual(payOrder.getPayPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY.getType())) { + log.warn("|{}|支付宝支付回调订单或平台不匹配,payNo:{}", TAG, payNo); + return false; + } + ThirdAggregatePaySessionEntity aggregateSession = aggregatePaySessionService == null + ? null : aggregatePaySessionService.getByPayNo(payNo); + boolean aggregateRequest = isAggregatePayRequest(payOrder); + if (aggregateRequest != (aggregateSession != null) + || (aggregateSession != null + && (!isMatchingAggregateSession(aggregateSession, payOrder) + || ObjectUtil.notEqual(aggregateSession.getSelectedPlatform(), AppPlusPlatformTypeEnum.ALI_PAY.getType())))) { + log.warn("|{}|支付宝聚合支付回调会话绑定不一致,payNo:{}", TAG, payNo); + return false; + } + try { + BigDecimal callbackAmount = new BigDecimal(params.get("total_amount")).setScale(2, RoundingMode.UNNECESSARY); + BigDecimal orderAmount = BigDecimal.valueOf(payOrder.getPrice()).movePointLeft(2).setScale(2, RoundingMode.UNNECESSARY); + if (callbackAmount.compareTo(orderAmount) != 0) { + log.warn("|{}|支付宝支付回调金额不匹配,payNo:{}", TAG, payNo); + return false; + } + } catch (Exception exception) { + log.warn("|{}|支付宝支付回调金额格式错误,payNo:{}", TAG, payNo); + return false; + } + return true; + } + + /** + * 校验聚合会话与实际第三方子订单的业务、商户和金额快照一致。 + * + * @param session {@code pay_no} 绑定的聚合支付会话 + * @param payOrder 支付宝回调对应的第三方子订单 + * @return 关键业务字段完全一致时返回 {@code true} + */ + private boolean isMatchingAggregateSession(ThirdAggregatePaySessionEntity session, ThirdPayOrderEntity payOrder) { + return ObjectUtil.equals(session.getPayNo(), payOrder.getPayNo()) + && ObjectUtil.equals(session.getBizType(), payOrder.getBizType()) + && ObjectUtil.equals(session.getBizId(), payOrder.getBizId()) + && ObjectUtil.equals(session.getMchId(), payOrder.getMchId()) + && ObjectUtil.equals(session.getPrice(), payOrder.getPrice()); + } + + /** + * 判断内部订单请求快照是否明确标记为支付宝聚合 JSAPI 子订单。 + * + * @param payOrder 回调商户订单号对应的内部支付订单 + * @return 请求快照同时包含聚合标记和 {@code JSAPI_PAY} 产品码时返回 {@code true} + */ + private boolean isAggregatePayRequest(ThirdPayOrderEntity payOrder) { + if (payOrder == null || StrUtil.isBlank(payOrder.getPayRep())) { + return false; + } + try { + JSONObject request = JSONUtil.parseObj(payOrder.getPayRep()); + return Boolean.TRUE.equals(request.getBool("aggregatePay")) + && ObjectUtil.equals(request.getStr("productCode"), "JSAPI_PAY"); + } catch (Exception exception) { + return false; + } + } + } 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 a9f49e36f..6d2c39d82 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 @@ -4,9 +4,13 @@ import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.RandomUtil; +import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; import com.alipay.api.internal.util.AlipaySignature; +import com.alipay.api.response.AlipayTradeCancelResponse; import com.alipay.api.response.AlipayTradeQueryResponse; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.bsd.pjcloud.admin.api.dto.UserInfo; import com.bsd.pjcloud.admin.api.dto.UserTokenDTO; import com.bsd.pjcloud.admin.api.feign.RemoteTokenService; @@ -34,6 +38,10 @@ import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.service.*; import com.bsd.pjcloud.thirdparty.vo.appplus.ThirdMinAppOAth2LoginVo; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdMiniAppPayVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayStatusVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.alipay.ThirdALiPayCallbackVo; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.wechat.*; import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayOrderVo; @@ -53,19 +61,26 @@ import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; import com.github.binarywang.wxpay.bean.result.WxPayMicropayResult; import com.wechat.pay.java.service.partnerpayments.app.model.Transaction; import com.wechat.pay.java.service.refund.model.RefundNotification; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.validation.annotation.Validated; +import java.time.Duration; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; /** - * 小程序(小程序版本) + * App、小程序及消费机第三方支付编排服务。 + * + *

负责选择商户支付配置、创建平台订单、处理回调、轮询状态和执行原有业务结果通知。

*/ @Service @Validated @@ -73,29 +88,128 @@ import java.util.Map; @RequiredArgsConstructor public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { - static private final String TAG = "小程序服务类"; + /** + * 支付编排日志标签,不包含支付配置、授权令牌或二维码内容。 + */ + private static final String TAG = "小程序服务类"; + /** + * 匿名消费机订单的内部用户ID占位值,用于区分空值和真实登录用户。 + */ + private static final long ANONYMOUS_ORDER_USER_ID = -1L; + + /** + * 订单码有效时长,和提交给支付宝、微信的支付截止时间保持一致。 + */ + private static final long ORDER_CODE_EXPIRE_MINUTES = 2L; + + /** + * 订单码幂等锁命名空间;租户前缀由 Redis 基础设施统一附加,业务层只拼接业务类型和业务ID。 + */ + private static final String ORDER_CODE_LOCK_PREFIX = "thirdparty:order-code-pay:lock:"; + + /** + * 订单码幂等锁最长持有时间;覆盖正常的查询、撤销和预创建网络调用。 + */ + private static final Duration ORDER_CODE_LOCK_TTL = Duration.ofSeconds(30); + + /** + * 支付宝撤销接口要求稍后重试时返回的标记值。 + */ + private static final String ALIPAY_CANCEL_RETRY = "Y"; + + /** + * 仅当 Redis 锁值仍属于当前请求时删除锁,避免超时后误删后继请求的锁。 + */ + private static final DefaultRedisScript ORDER_CODE_UNLOCK_SCRIPT = new DefaultRedisScript<>( + "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", Long.class); + + /** + * App 与小程序支付应用配置,来源于现有 app-plus.pay 配置中心。 + */ private final AppPlusPayConfig appPlusPayConfig; + + /** + * 微信支付服务,负责微信下单、查询、退款和回调解析。 + */ private final ThirdAppPlusWechatService thirdMiniAppWechatService; + + /** + * 微信服务商 Native 支付服务,负责订单码下单、查询和关单。 + */ + private final ThirdAppPlusWechatNativePayService thirdAppPlusWechatNativePayService; + + /** + * 微信付款码支付服务,负责云消费机主动扫用户付款码的旧支付流程。 + */ private final ThirdAppPlusWechatMicropayService thirdAppPlusWechatMicropayService; + + /** + * 支付宝支付服务,负责应用支付、订单码预创建、查询、撤销和回调验真。 + */ private final ThirdAppPlusAliPayService thirdMiniAppAliPayService; + + /** + * 第三方支付日志服务,由既有支付流程用于记录平台交互日志。 + */ private final ThirdPayLogService thirdPayLogService; + + /** + * 第三方支付订单服务,持有支付状态和商户配置快照的持久化边界。 + */ private final ThirdPayOrderService thirdPayOrderService; + /** + * 支付宝与微信聚合订单码编排服务,旧服务仅负责公开契约委托和跨模式互斥。 + */ + private final ThirdAggregateOrderPayService thirdAggregateOrderPayService; + + /** + * Redis 字符串模板,用于同一租户业务订单的短时并发互斥。 + */ + private final StringRedisTemplate stringRedisTemplate; + + /** + * 用户远程服务,用于现有登录、用户归属商户和钱包支付流程。 + */ private final RemoteUserService remoteUserService; - /** 用户关系远程服务,用于支付发起前再次校验监护授权。 */ + /** + * 用户关系远程服务,用于支付发起前再次校验监护授权。 + */ private final RemoteUserRelationService remoteUserRelationService; + /** + * 令牌远程服务,用于现有小程序登录成功后的 Token 签发。 + */ private final RemoteTokenService remoteTokenService; + + /** + * App 用户远程服务,用于现有小程序账号同步。 + */ private final RemoteAppUserService remoteAppUserService; + + /** + * 钱包充值套餐远程服务,用于解析充值金额并接收支付、退款结果。 + */ private final RemoteWalletRechargePackageService remoteWalletRechargePackageService; - /** 充值记录远程服务,用于类型 40 解析可信金额和目标钱包。 */ + /** + * 充值记录远程服务,用于类型 40 解析可信金额和目标钱包。 + */ private final RemoteWalletRechargeService remoteWalletRechargeService; - /** 钱包远程服务,用于确认目标饭卡状态与所属食堂。 */ + /** + * 钱包远程服务,用于确认目标饭卡状态与所属食堂。 + */ private final RemoteWalletService remoteWalletService; + /** + * 商户远程服务,订单码支付仅按请求中的商户ID读取商户及启用配置。 + */ private final RemoteMchService remoteMchService; + + /** + * 硬件订单远程服务,仅供原有 App 商品订单支付结果回调使用。 + */ private final RemoteHardOrderService remoteHardOrderService; /** @@ -313,9 +427,6 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { /** * 查询当前付款人所属商户,原本人充值和商城支付继续沿用同一套商户解析逻辑。 - * - * @param userId 当前付款人用户ID - * @return 当前付款人所属商户 */ private MchManageVo getUserMerchant(Long userId) { R userResult = remoteUserService.getByUserId(userId); @@ -408,6 +519,349 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { return null; } + /** + * 创建支付宝或微信订单码支付订单,并在同租户业务维度复用同平台仍有效的二维码。 + * + * @param dto 业务订单、收款商户、金额和扫码支付平台参数 + * @return 支付订单号、平台二维码内容串和服务端过期时间 + */ + @Override + public ThirdOrderCodePayVo orderCodePay(@Valid ThirdOrderCodePayDto dto) { + validateOrderCodePayRequest(dto); + String lockKey = buildOrderCodePayLockKey(dto); + String lockToken = UUID.randomUUID().toString(); + Boolean lockAcquired = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockToken, ORDER_CODE_LOCK_TTL); + if (!Boolean.TRUE.equals(lockAcquired)) { + throw new RuntimeException("订单码支付正在处理中,请勿重复提交"); + } + try { + return createOrReuseOrderCodePay(dto); + } finally { + releaseOrderCodePayLock(lockKey, lockToken); + } + } + + /** + * 查询订单码支付的最小状态,不透出商户配置和第三方支付报文。 + */ + @Override + public ThirdOrderCodePayStatusVo getOrderCodePayStatus(String payNo) { + if (StrUtil.isBlank(payNo)) { + throw new RuntimeException("支付订单号不能为空"); + } + ThirdPayOrderEntity payOrder = thirdPayOrderService.getByPayNo(payNo); + if (payOrder == null + || ObjectUtil.notEqual(payOrder.getBizType(), ThirdPayOrderBizTypeEnum.YXFJ_PAY.getType()) + || !isSupportedOrderCodePlatform(payOrder.getPayPlatformType())) { + throw new RuntimeException("订单码支付订单不存在"); + } + ThirdOrderCodePayStatusVo result = new ThirdOrderCodePayStatusVo(); + result.setPayNo(payOrder.getPayNo()); + result.setStatus(payOrder.getStatus()); + result.setPaySuccess(ObjectUtil.equals(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())); + result.setExpireTime(payOrder.getExpireTime()); + return result; + } + + /** + * 委托聚合支付服务创建或复用支付宝、微信共用二维码。 + * + * @param dto 业务订单、收款商户、金额和可选用户参数 + * @return 聚合会话编号、HTTPS 二维码内容和失效时间 + */ + @Override + public ThirdAggregateOrderCodePayVo aggregateOrderCodePay(@Valid ThirdAggregateOrderCodePayDto dto) { + return thirdAggregateOrderPayService.create(dto); + } + + /** + * 委托聚合支付服务查询最小会话状态。 + * + * @param aggregateNo 聚合支付会话编号 + * @return 聚合状态、选定平台和第三方子订单号 + */ + @Override + public ThirdAggregateOrderCodePayStatusVo getAggregateOrderCodePayStatus(String aggregateNo) { + return thirdAggregateOrderPayService.getStatus(aggregateNo); + } + + /** + * 在已经取得业务锁后复用有效订单或创建新的支付宝、微信订单码。 + */ + private ThirdOrderCodePayVo createOrReuseOrderCodePay(ThirdOrderCodePayDto dto) { + thirdAggregateOrderPayService.assertNoBlockingAggregateSession(dto.getBizType(), dto.getBizId()); + if (hasSuccessfulOrderCodePay(dto.getBizType(), dto.getBizId())) { + throw new RuntimeException("该业务订单已支付,请勿重复下单"); + } + ThirdPayOrderEntity latestOrder = getLatestOrderCodePay(dto.getBizType(), dto.getBizId()); + if (latestOrder != null) { + if (ObjectUtil.equals(latestOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) { + throw new RuntimeException("该业务订单已支付,请勿重复下单"); + } + if (ObjectUtil.equals(latestOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) { + validateReusableOrder(latestOrder, dto); + String qrCode = getOrderCodeQrCode(latestOrder); + if (latestOrder.getExpireTime() != null && latestOrder.getExpireTime().isAfter(LocalDateTime.now()) && StrUtil.isNotBlank(qrCode)) { + if (ObjectUtil.notEqual(latestOrder.getPayPlatformType(), dto.getMiniPlatformType().getType())) { + throw new RuntimeException("该业务订单已有其他平台的待支付订单码"); + } + return buildOrderCodePayVo(latestOrder, qrCode); + } + + // 过期订单必须先向原支付平台确认并关闭,不能仅按本地时间直接创建第二笔可支付交易。 + queryPayResult(latestOrder); + latestOrder = thirdPayOrderService.getById(latestOrder.getId()); + if (latestOrder != null && ObjectUtil.equals(latestOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) { + throw new RuntimeException("该业务订单已支付,请勿重复下单"); + } + if (latestOrder != null && ObjectUtil.equals(latestOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) { + if (latestOrder.getExpireTime() != null && latestOrder.getExpireTime().isAfter(LocalDateTime.now())) { + throw new RuntimeException("订单码数据异常,请稍后重试"); + } + throw new RuntimeException("上一笔订单正在确认关闭,请稍后重试"); + } + } + } + + MchManageVo merchant = getOrderCodePayMerchant(dto.getMchId()); + ThirdAppPlusPayDto payDto = new ThirdAppPlusPayDto(); + payDto.setOutTradeNo(createPayNo()); + payDto.setBizId(dto.getBizId()); + payDto.setBizType(dto.getBizType()); + payDto.setUserId(dto.getUserId() == null ? ANONYMOUS_ORDER_USER_ID : dto.getUserId()); + payDto.setAmount(dto.getAmount()); + payDto.setMchId(merchant.getId()); + LocalDateTime expireTime = LocalDateTime.now().plusMinutes(ORDER_CODE_EXPIRE_MINUTES); + if (ObjectUtil.equals(dto.getMiniPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY)) { + MchPaySettingVo paySetting = getEnabledAliPaySetting(merchant); + validateAliPayApplicationConfig(); + return thirdMiniAppAliPayService.precreate(payDto, paySetting, expireTime); + } + MchPaySettingVo paySetting = getEnabledWechatPaySetting(merchant); + validateWechatApplicationConfig(); + return thirdAppPlusWechatNativePayService.precreate(payDto, paySetting, expireTime); + } + + /** + * 校验订单码支付首版允许的业务、平台、金额和商户参数。 + */ + private void validateOrderCodePayRequest(ThirdOrderCodePayDto dto) { + if (dto == null) { + throw new RuntimeException("订单码支付参数不能为空"); + } + if (ObjectUtil.notEqual(dto.getBizType(), ThirdPayOrderBizTypeEnum.YXFJ_PAY)) { + throw new RuntimeException("订单码支付仅支持云消费机支付业务"); + } + if (dto.getMiniPlatformType() == null || !isSupportedOrderCodePlatform(dto.getMiniPlatformType().getType())) { + throw new RuntimeException("订单码支付仅支持支付宝或微信"); + } + if (StrUtil.isBlank(dto.getBizId())) { + throw new RuntimeException("支付业务id不能为空"); + } + if (dto.getMchId() == null) { + throw new RuntimeException("商户id不能为空"); + } + if (dto.getAmount() == null || dto.getAmount() <= 0) { + throw new RuntimeException("支付金额必须大于0"); + } + } + + /** + * 查询同租户、同业务类型和业务ID下最新创建的支付宝或微信订单码支付订单。 + */ + private ThirdPayOrderEntity getLatestOrderCodePay(ThirdPayOrderBizTypeEnum bizType, String bizId) { + return thirdPayOrderService.getOne(new LambdaQueryWrapper() + .eq(ThirdPayOrderEntity::getBizType, bizType.getType()) + .eq(ThirdPayOrderEntity::getBizId, bizId) + .in(ThirdPayOrderEntity::getPayPlatformType, + AppPlusPlatformTypeEnum.ALI_PAY.getType(), AppPlusPlatformTypeEnum.WECHAT.getType()) + .isNotNull(ThirdPayOrderEntity::getExpireTime) + .notExists("SELECT 1 FROM third_aggregate_pay_session s WHERE s.pay_no = third_pay_order.pay_no AND s.del_flag = '0'") + .orderByDesc(ThirdPayOrderEntity::getCreateTime) + .last("limit 1")); + } + + /** + * 判断同租户业务订单是否已经存在支付成功的支付宝或微信订单码。 + */ + private boolean hasSuccessfulOrderCodePay(ThirdPayOrderBizTypeEnum bizType, String bizId) { + return thirdPayOrderService.count(new LambdaQueryWrapper() + .eq(ThirdPayOrderEntity::getBizType, bizType.getType()) + .eq(ThirdPayOrderEntity::getBizId, bizId) + .in(ThirdPayOrderEntity::getPayPlatformType, AppPlusPlatformTypeEnum.ALI_PAY.getType(), AppPlusPlatformTypeEnum.WECHAT.getType()) + .isNotNull(ThirdPayOrderEntity::getExpireTime) + .notExists("SELECT 1 FROM third_aggregate_pay_session s WHERE s.pay_no = third_pay_order.pay_no AND s.del_flag = '0'") + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) > 0; + } + + /** + * 校验待复用订单与本次请求的收款商户和金额完全一致。 + */ + private void validateReusableOrder(ThirdPayOrderEntity payOrder, ThirdOrderCodePayDto dto) { + if (ObjectUtil.notEqual(payOrder.getMchId(), dto.getMchId()) || ObjectUtil.notEqual(payOrder.getPrice(), dto.getAmount())) { + throw new RuntimeException("该业务订单已有不同金额或商户的待支付订单"); + } + } + + /** + * 从预创建响应快照中提取支付宝或微信二维码内容串。 + */ + private String getOrderCodeQrCode(ThirdPayOrderEntity payOrder) { + if (payOrder == null || StrUtil.isBlank(payOrder.getPayRes())) { + return null; + } + try { + return JSONUtil.parseObj(payOrder.getPayRes()).getStr("qrCode"); + } catch (Exception exception) { + log.warn("|{}|订单码响应快照解析失败,payNo:{}", TAG, payOrder.getPayNo()); + return null; + } + } + + /** + * 将已有支付订单转换为可复用的订单码创建结果。 + */ + private ThirdOrderCodePayVo buildOrderCodePayVo(ThirdPayOrderEntity payOrder, String qrCode) { + ThirdOrderCodePayVo result = new ThirdOrderCodePayVo(); + result.setPayNo(payOrder.getPayNo()); + result.setQrCode(qrCode); + result.setExpireTime(payOrder.getExpireTime()); + return result; + } + + /** + * 仅按请求中的商户ID读取收款商户,并校验远程调用业务状态。 + */ + private MchManageVo getOrderCodePayMerchant(Long mchId) { + R merchantResult = remoteMchService.getById(mchId); + MchManageVo merchant = merchantResult == null || !merchantResult.isOk() ? null : merchantResult.getData(); + if (merchant == null) { + throw new RuntimeException("收款商户不存在"); + } + if (merchant.getId() == null || ObjectUtil.notEqual(merchant.getId(), mchId)) { + throw new RuntimeException("收款商户信息不一致"); + } + if (ObjectUtil.isEmpty(merchant.getPaySettingList())) { + throw new RuntimeException("商户未配置支付参数"); + } + return merchant; + } + + /** + * 从商户详情中选择启用的支付宝配置并校验应用授权令牌。 + */ + private MchPaySettingVo getEnabledAliPaySetting(MchManageVo merchant) { + MchPaySettingVo paySetting = merchant.getPaySettingList().stream() + .filter(item -> ObjectUtil.equals(item.getPlatformType(), AppPlusPlatformPayType.ALI_PAY.getType())) + .filter(item -> ObjectUtil.equals(item.getEnable(), YesNoEnum.YES.getCode())) + .findFirst() + .orElse(null); + if (paySetting == null || StrUtil.isBlank(paySetting.getSettingJson())) { + throw new RuntimeException("商户未启用支付宝支付配置"); + } + try { + MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + if (aliPaySetting == null || StrUtil.isBlank(aliPaySetting.getAppAuthToken())) { + throw new RuntimeException("商户支付宝授权Token未配置"); + } + } catch (RuntimeException exception) { + if ("商户支付宝授权Token未配置".equals(exception.getMessage())) { + throw exception; + } + throw new RuntimeException("商户支付宝支付配置无效"); + } + return paySetting; + } + + /** + * 从商户详情中选择启用的微信支付配置并校验服务商模式子商户号。 + */ + private MchPaySettingVo getEnabledWechatPaySetting(MchManageVo merchant) { + MchPaySettingVo paySetting = merchant.getPaySettingList().stream() + .filter(item -> ObjectUtil.equals(item.getPlatformType(), AppPlusPlatformPayType.WECHAT.getType())) + .filter(item -> ObjectUtil.equals(item.getEnable(), YesNoEnum.YES.getCode())) + .findFirst() + .orElse(null); + if (paySetting == null || StrUtil.isBlank(paySetting.getSettingJson())) { + throw new RuntimeException("商户未启用微信支付配置"); + } + try { + MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean( + paySetting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class); + if (wechatPaySetting == null || StrUtil.isBlank(wechatPaySetting.getSubMchid())) { + throw new RuntimeException("商户微信子商户号未配置"); + } + } catch (RuntimeException exception) { + if ("商户微信子商户号未配置".equals(exception.getMessage())) { + throw exception; + } + throw new RuntimeException("商户微信支付配置无效"); + } + return paySetting; + } + + /** + * 校验现有支付宝应用配置满足预创建、回调验签和回调地址拼接要求。 + */ + private void validateAliPayApplicationConfig() { + AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.getAliPayConfig(); + if (config == null || StrUtil.hasBlank(config.getAppId(), config.getAppPrivateKey(), config.getAlipayPublicKey(), + config.getBaseCallbackUrl(), config.getPayCallbackUrl())) { + throw new RuntimeException("支付宝应用支付配置不完整"); + } + } + + /** + * 校验现有微信服务商应用配置满足 Native 下单、回调验签和回调地址拼接要求。 + */ + private void validateWechatApplicationConfig() { + AppPlusPayConfig.WechatConfig config = appPlusPayConfig.getWechatConfig(); + if (config == null || StrUtil.hasBlank(config.getAppPlatformAppId(), config.getMerchantId(), + config.getMchPrivatePath(), config.getMerchantSerialNumber(), config.getApiV3Key(), + config.getBaseCallbackUrl(), config.getPayCallbackUrl())) { + throw new RuntimeException("微信服务商支付配置不完整"); + } + } + + /** + * 判断平台持久化编码是否属于订单码支持的支付宝或微信平台。 + */ + private boolean isSupportedOrderCodePlatform(String platformType) { + return ObjectUtil.equals(platformType, AppPlusPlatformTypeEnum.ALI_PAY.getType()) + || ObjectUtil.equals(platformType, AppPlusPlatformTypeEnum.WECHAT.getType()); + } + + /** + * 生成符合支付宝商户订单号长度限制的唯一支付订单号。 + */ + private String createPayNo() { + return LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + RandomUtil.randomString(10); + } + + /** + * 生成业务维度的订单码幂等锁键,租户隔离交由 Redis 基础设施统一处理。 + * + * @param dto 已校验的订单码支付请求 + * @return 仅包含业务命名空间、业务类型和业务ID的 Redis 锁键 + */ + private String buildOrderCodePayLockKey(ThirdOrderCodePayDto dto) { + return ORDER_CODE_LOCK_PREFIX + dto.getBizType().getType() + ":" + dto.getBizId(); + } + + /** + * 使用令牌校验脚本释放订单码幂等锁;Redis 异常不覆盖主支付结果。 + * + * @param lockKey 当前请求的 Redis 锁键 + * @param lockToken 当前请求持有的随机锁令牌 + */ + private void releaseOrderCodePayLock(String lockKey, String lockToken) { + try { + stringRedisTemplate.execute(ORDER_CODE_UNLOCK_SCRIPT, List.of(lockKey), lockToken); + } catch (Exception exception) { + log.warn("|{}|订单码支付锁释放异常", TAG, exception); + } + } + /** * 小程序退款 */ @@ -479,39 +933,51 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { } /** - * 支付回调 + * 处理共享支付回调并返回平台要求的纯文本确认结果。 + * + * @param params 表单回调参数,支付宝回调使用 + * @param body 原始请求体,微信回调使用 + * @param headers 原始请求头,微信回调验签使用 + * @return 平台要求的 {@code success} 或 {@code fail} */ @Override public String miniPayCallback(Map params, String body, Map headers) { AppPlusPlatformTypeEnum platformType = checkMiniPlatformType(params, body, headers); - ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity(); - - // 微信小程序 + // 微信支付验签与关键字段校验已在微信服务中完成;重复成功通知不会再次推进支付状态。 if (ObjectUtil.equals(platformType, AppPlusPlatformTypeEnum.WECHAT)) { ThirdPayOrderEntity thirdPayOrder = thirdMiniAppWechatService.payCallback(body, headers); if (ObjectUtil.isEmpty(thirdPayOrder)) { return "fail"; } - // 根据订单bizType来处理 - boolean isSuccess = disposePaySuccessAfter(thirdPayOrder); - if (isSuccess) { - updateObj.setId(thirdPayOrder.getId()); - updateObj.setIsBizCallback(YesNoEnum.YES.getCode()); - thirdPayOrderService.updateById(updateObj); + if (thirdAggregateOrderPayService.syncAggregateChildOrder(thirdPayOrder)) { + return "success"; } - return isSuccess ? "success" : "fail"; + markBusinessCallbackIfSuccessful(thirdPayOrder); + ThirdPayOrderEntity latestOrder = thirdPayOrderService.getById(thirdPayOrder.getId()); + return latestOrder != null + && ObjectUtil.equals(latestOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()) + && ObjectUtil.equals(latestOrder.getIsBizCallback(), YesNoEnum.YES.getCode()) ? "success" : "fail"; } // 支付宝 else if (ObjectUtil.equals(platformType, AppPlusPlatformTypeEnum.ALI_PAY)) { - ThirdPayOrderEntity thirdPayOrder = thirdMiniAppAliPayService.payCallback(params); - if (ObjectUtil.isEmpty(thirdPayOrder)) { + ThirdAppPlusAliPayService.AliPayCallbackResult callbackResult = thirdMiniAppAliPayService.payCallback(params); + if (callbackResult == null) { return ThirdALiPayCallbackVo.aliPayFail(); } + if (thirdAggregateOrderPayService.syncAggregateChildOrder(callbackResult.getPayOrder())) { + return ThirdALiPayCallbackVo.aliPaySuccess(); + } + // 已关闭通知和重复成功通知均已完成平台确认,不再重复触发下游业务处理。 + if (!callbackResult.isPaySuccess() || !callbackResult.isStateChanged()) { + return ThirdALiPayCallbackVo.aliPaySuccess(); + } + ThirdPayOrderEntity thirdPayOrder = callbackResult.getPayOrder(); // 根据订单bizType来处理 boolean isSuccess = disposePaySuccessAfter(thirdPayOrder); // 更新 if (isSuccess) { + ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity(); updateObj.setId(thirdPayOrder.getId()); updateObj.setIsBizCallback(YesNoEnum.YES.getCode()); thirdPayOrderService.updateById(updateObj); @@ -520,70 +986,210 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { } // 不支持的支付类型 else { - return null; + return "fail"; } } + /** + * 主动查询支付平台状态并以未支付条件原子推进内部订单状态。 + * + *

订单码到期仍待支付时会调用对应平台的撤销或关单接口;网络错误和可重试结果均保留未支付状态。

+ * + * @param dto 需要查询的内部支付订单 + */ @Override public void queryPayResult(ThirdPayOrderEntity dto) { - ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity(); - boolean isUpdateOrder = false; - // 微信小程序 - if (ObjectUtil.equals(dto.getPayPlatformType(), AppPlusPlatformTypeEnum.WECHAT)) { + if (dto == null || dto.getId() == null) { + return; + } + // 聚合 JSAPI 子订单由独立任务按会话平台查询,禁止在旧入口中误走微信 Native 查询或旧业务回调。 + if (thirdAggregateOrderPayService.syncAggregateChildOrder(dto)) { + return; + } + // 微信支付:订单码使用 Native 服务,原有 App/小程序订单继续沿用既有查询服务。 + if (ObjectUtil.equals(dto.getPayPlatformType(), AppPlusPlatformTypeEnum.WECHAT.getType())) { + if (isOrderCodePay(dto)) { + queryWechatOrderCodePayResult(dto); + return; + } Transaction thirdPayOrder = thirdMiniAppWechatService.query(dto.getPayNo(), dto.getMchPaySettingJson()); if (ObjectUtil.isEmpty(thirdPayOrder) || ObjectUtil.isEmpty(thirdPayOrder.getTradeState())) { return; } Transaction.TradeStateEnum tradeState = thirdPayOrder.getTradeState(); if (ObjectUtil.equals(tradeState, Transaction.TradeStateEnum.SUCCESS)) { - if (ObjectUtil.equals(dto.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) { - // 执行支付成功的代码 - updateObj.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); - updateObj.setPayTime(LocalDateTime.now()); - isUpdateOrder = true; - } - if (ObjectUtil.notEqual(dto.getIsBizCallback(), YesNoEnum.YES.getCode())) { - dto.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); - boolean isSuccess = disposePaySuccessAfter(dto); - if (isSuccess) { - isUpdateOrder = true; - updateObj.setIsBizCallback(YesNoEnum.YES.getCode()); - thirdPayOrderService.updateById(updateObj); - } + boolean stateChanged = updatePayOrderSuccess(dto, thirdPayOrder.getTransactionId()); + if (stateChanged || (ObjectUtil.equals(dto.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()) + && ObjectUtil.notEqual(dto.getIsBizCallback(), YesNoEnum.YES.getCode()))) { + markBusinessCallbackIfSuccessful(thirdPayOrderService.getById(dto.getId())); } } } // 支付宝 - else if (ObjectUtil.equals(dto.getPayPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY)) { + else if (ObjectUtil.equals(dto.getPayPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY.getType())) { AlipayTradeQueryResponse aliRes = thirdMiniAppAliPayService.query(dto.getPayNo(), dto.getMchPaySettingJson()); - if (ObjectUtil.isEmpty(aliRes) || ObjectUtil.isEmpty(aliRes.getTradeStatus())) { + if (aliRes == null) { return; } String tradeStatus = aliRes.getTradeStatus(); - if (ObjectUtil.equals(tradeStatus, "TRADE_SUCCESS")) { - if (ObjectUtil.equals(dto.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) { - // 执行支付成功的代码 - updateObj.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); - updateObj.setPayTime(LocalDateTime.now()); - isUpdateOrder = true; - } - if (ObjectUtil.notEqual(dto.getIsBizCallback(), YesNoEnum.YES.getCode())) { - dto.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); - boolean isSuccess = disposePaySuccessAfter(dto); - if (isSuccess) { - isUpdateOrder = true; - updateObj.setIsBizCallback(YesNoEnum.YES.getCode()); - thirdPayOrderService.updateById(updateObj); - } + if (ObjectUtil.equals(tradeStatus, "TRADE_SUCCESS") || ObjectUtil.equals(tradeStatus, "TRADE_FINISHED")) { + boolean stateChanged = updatePayOrderSuccess(dto, aliRes.getTradeNo()); + if (stateChanged || (ObjectUtil.equals(dto.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()) + && ObjectUtil.notEqual(dto.getIsBizCallback(), YesNoEnum.YES.getCode()))) { + markBusinessCallbackIfSuccessful(thirdPayOrderService.getById(dto.getId())); } + } else if (ObjectUtil.equals(tradeStatus, "TRADE_CLOSED")) { + updateUnpaidOrderToClosed(dto.getId()); + } else if (isExpiredOrderCodePay(dto)) { + closeExpiredOrderCodePay(dto); } } - // 不支持的支付类型 - else { + } + + /** + * 将未支付的第三方订单原子更新为支付成功并记录平台交易号。 + * + * @param payOrder 当前内部支付订单 + * @param tradeNo 支付平台交易号 + * @return 本次是否完成从未支付到支付成功的状态推进 + */ + private boolean updatePayOrderSuccess(ThirdPayOrderEntity payOrder, String tradeNo) { + ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity(); + updateObj.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); + updateObj.setPayTime(LocalDateTime.now()); + updateObj.setThirdPayNo(tradeNo); + return thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, payOrder.getId()) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType())); + } + + /** + * 查询微信服务商 Native 订单码并处理成功、关闭、失败和到期关单状态。 + * + * @param payOrder 当前仍需确认状态的微信订单码内部订单 + */ + private void queryWechatOrderCodePayResult(ThirdPayOrderEntity payOrder) { + com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction transaction = + thirdAppPlusWechatNativePayService.query(payOrder.getPayNo(), payOrder.getMchPaySettingJson()); + if (transaction == null || transaction.getTradeState() == null) { return; } - if (isUpdateOrder) { - thirdPayOrderService.updateById(updateObj); + com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction.TradeStateEnum tradeState = transaction.getTradeState(); + if (ObjectUtil.equals(tradeState, + com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction.TradeStateEnum.SUCCESS)) { + boolean stateChanged = updatePayOrderSuccess(payOrder, transaction.getTransactionId()); + if (stateChanged || (ObjectUtil.equals(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()) + && ObjectUtil.notEqual(payOrder.getIsBizCallback(), YesNoEnum.YES.getCode()))) { + markBusinessCallbackIfSuccessful(thirdPayOrderService.getById(payOrder.getId())); + } + return; + } + if (ObjectUtil.equals(tradeState, + com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction.TradeStateEnum.CLOSED) + || ObjectUtil.equals(tradeState, + com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction.TradeStateEnum.REVOKED)) { + updateUnpaidOrderToClosed(payOrder.getId()); + return; + } + if (ObjectUtil.equals(tradeState, + com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction.TradeStateEnum.PAYERROR)) { + updateUnpaidOrderToFailed(payOrder.getId()); + return; + } + if (isExpiredOrderCodePay(payOrder)) { + closeExpiredOrderCodePay(payOrder); + } + } + + /** + * 将未支付订单原子推进为交易关闭,避免覆盖并发到达的支付成功状态。 + * + * @param payOrderId 内部支付订单主键 + * @return 本次是否完成关闭状态推进 + */ + private boolean updateUnpaidOrderToClosed(Long payOrderId) { + ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity(); + updateObj.setStatus(ThirdPayOrderStatusEnum.PAY_CLOSED.getType()); + return thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, payOrderId) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType())); + } + + /** + * 将未支付订单原子推进为支付失败,用于微信明确返回 {@code PAYERROR} 的异常终态。 + * + * @param payOrderId 内部支付订单主键 + * @return 本次是否完成失败状态推进 + */ + private boolean updateUnpaidOrderToFailed(Long payOrderId) { + ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity(); + updateObj.setStatus(ThirdPayOrderStatusEnum.PAY_FAILED.getType()); + return thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, payOrderId) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType())); + } + + /** + * 判断支付订单是否属于本次支付宝或微信云消费机订单码能力。 + * + * @param payOrder 当前内部支付订单 + * @return 业务类型、平台和过期时间均符合订单码特征时返回 {@code true} + */ + private boolean isOrderCodePay(ThirdPayOrderEntity payOrder) { + return payOrder != null + && ObjectUtil.equals(payOrder.getBizType(), ThirdPayOrderBizTypeEnum.YXFJ_PAY.getType()) + && isSupportedOrderCodePlatform(payOrder.getPayPlatformType()) + && payOrder.getExpireTime() != null; + } + + /** + * 判断订单是否属于已到期但仍未支付的支付宝或微信云消费机订单码。 + * + * @param payOrder 当前内部支付订单 + * @return 满足订单码业务、未支付且服务端已到期时返回 {@code true} + */ + private boolean isExpiredOrderCodePay(ThirdPayOrderEntity payOrder) { + return isOrderCodePay(payOrder) + && ObjectUtil.equals(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType()) + && !payOrder.getExpireTime().isAfter(LocalDateTime.now()); + } + + /** + * 关闭已经到期的支付宝或微信订单码;需重试、网络异常或未知结果均保留未支付状态。 + * + * @param payOrder 已到期且查询未确认成功的订单码支付订单 + */ + private void closeExpiredOrderCodePay(ThirdPayOrderEntity payOrder) { + if (ObjectUtil.equals(payOrder.getPayPlatformType(), AppPlusPlatformTypeEnum.WECHAT.getType())) { + if (thirdAppPlusWechatNativePayService.close(payOrder.getPayNo(), payOrder.getMchPaySettingJson())) { + updateUnpaidOrderToClosed(payOrder.getId()); + } + return; + } + AlipayTradeCancelResponse cancelResponse = thirdMiniAppAliPayService.cancel(payOrder.getPayNo(), payOrder.getMchPaySettingJson()); + if (cancelResponse != null && cancelResponse.isSuccess() + && ObjectUtil.notEqual(cancelResponse.getRetryFlag(), ALIPAY_CANCEL_RETRY) + && (ObjectUtil.equals(cancelResponse.getAction(), "close") || ObjectUtil.equals(cancelResponse.getAction(), "refund"))) { + updateUnpaidOrderToClosed(payOrder.getId()); + } + } + + /** + * 支付成功后执行原有业务回调,并仅在业务处理成功时记录完成标识。 + * + * @param payOrder 已确认支付成功的最新内部订单 + */ + private void markBusinessCallbackIfSuccessful(ThirdPayOrderEntity payOrder) { + if (payOrder == null || ObjectUtil.notEqual(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()) + || ObjectUtil.equals(payOrder.getIsBizCallback(), YesNoEnum.YES.getCode())) { + return; + } + if (disposePaySuccessAfter(payOrder)) { + ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity(); + updateObj.setIsBizCallback(YesNoEnum.YES.getCode()); + thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, payOrder.getId()) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())); } } @@ -631,7 +1237,14 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { return "success"; } - // 判断支付回调 + /** + * 识别共享支付回调所属平台;支付宝分支先执行 RSA2 验签再返回平台类型。 + * + * @param params 表单回调参数 + * @param body 原始请求体 + * @param headers 原始请求头 + * @return 已验真的支付平台;无法识别或验签失败时返回 {@code null} + */ private AppPlusPlatformTypeEnum checkMiniPlatformType(Map params, String body, Map headers) { Transaction transaction = thirdMiniAppWechatService.pasePayCallback(body, headers); // 微信 @@ -644,7 +1257,7 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { return AppPlusPlatformTypeEnum.ALI_PAY; } } catch (Exception e) { - log.error("|{}|判断支付回调alipay sdk验签错误,body:|{}|parameterMap:{||}", TAG, body, JSONUtil.toJsonStr(params)); + log.warn("|{}|判断支付宝支付回调验签异常,payNo:{}", TAG, params.get("out_trade_no")); } } return null; @@ -671,6 +1284,12 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { return null; } + /** + * 根据业务类型执行原有支付成功后置处理;云消费机订单码本次仅确认 thirdparty 订单,不跨服务更新硬件订单。 + * + * @param thirdPayOrder 已确认支付成功的内部支付订单 + * @return 后置处理成功或该业务无需后置处理时返回 {@code true} + */ public boolean disposePaySuccessAfter(ThirdPayOrderEntity thirdPayOrder) { if (ObjectUtil.notEqual(thirdPayOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) { return true; @@ -704,6 +1323,12 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { } } + /** + * 根据业务类型执行原有退款成功后置处理。 + * + * @param thirdPayOrder 已确认退款成功的内部支付订单 + * @return 后置处理成功或该业务无需后置处理时返回 {@code true} + */ private boolean disposeRefundSuccessAfter(ThirdPayOrderEntity thirdPayOrder) { if (ObjectUtil.notEqual(thirdPayOrder.getStatus(), ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType())) { return true; diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatNativePayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatNativePayServiceImpl.java new file mode 100644 index 000000000..627533350 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatNativePayServiceImpl.java @@ -0,0 +1,269 @@ +package com.bsd.pjcloud.thirdparty.service.impl.appplus; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatNativePayService; +import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import com.wechat.pay.java.core.exception.ServiceException; +import com.wechat.pay.java.service.partnerpayments.nativepay.NativePayService; +import com.wechat.pay.java.service.partnerpayments.nativepay.model.Amount; +import com.wechat.pay.java.service.partnerpayments.nativepay.model.CloseOrderRequest; +import com.wechat.pay.java.service.partnerpayments.nativepay.model.PrepayRequest; +import com.wechat.pay.java.service.partnerpayments.nativepay.model.PrepayResponse; +import com.wechat.pay.java.service.partnerpayments.nativepay.model.QueryOrderByOutTradeNoRequest; +import com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +/** + * 微信支付服务商模式 Native 订单码实现。 + * + *

使用项目现有微信支付 APIv3 SDK 与服务商证书配置,金额始终以整数分传递。

+ */ +@Service +@Validated +@Slf4j +@RequiredArgsConstructor +public class ThirdAppPlusWechatNativePayServiceImpl implements ThirdAppPlusWechatNativePayService { + + /** + * 微信订单码日志标签,不包含商户证书、APIv3 密钥或二维码内容。 + */ + private static final String TAG = "微信服务商Native支付"; + + /** + * 微信账单展示的固定商品描述,避免向第三方平台传递内部订单隐私。 + */ + private static final String ORDER_CODE_DESCRIPTION = "资安信消费支付"; + + /** + * 微信订单码使用的人民币币种编码。 + */ + private static final String CURRENCY_CNY = "CNY"; + + /** + * 微信支付服务商 Native SDK 服务,复用应用级证书与签名配置。 + */ + private final NativePayService nativePayService; + + /** + * 微信应用与服务商配置,来源于现有 {@code app-plus.pay} 配置中心。 + */ + private final AppPlusPayConfig appPlusPayConfig; + + /** + * 第三方支付订单服务,用于保存请求、响应和失败快照。 + */ + private final ThirdPayOrderService thirdPayOrderService; + + /** + * 创建微信服务商 Native 订单,只有 SDK 返回非空 {@code code_url} 才视为成功。 + * + * @param dto 已生成商户订单号的内部支付参数,金额单位为人民币分 + * @param setting 当前子商户启用的微信支付配置 + * @param expireTime 服务端订单码过期时间,同时作为微信支付截止时间 + * @return 支付订单号、二维码内容串和服务端过期时间 + */ + @Override + public ThirdOrderCodePayVo precreate(ThirdAppPlusPayDto dto, MchPaySettingVo setting, LocalDateTime expireTime) { + MchPaySettingEntity.WechatPaySetting wechatPaySetting = parseWechatPaySetting(setting); + AppPlusPayConfig.WechatConfig wechatConfig = appPlusPayConfig.getWechatConfig(); + ThirdPayOrderEntity payOrder = buildOrderCodePayOrder(dto, setting, expireTime); + + Amount amount = new Amount(); + amount.setTotal(dto.getAmount()); + amount.setCurrency(CURRENCY_CNY); + + PrepayRequest request = new PrepayRequest(); + request.setSpAppid(wechatConfig.getAppPlatformAppId()); + request.setSpMchid(wechatConfig.getMerchantId()); + request.setSubMchid(wechatPaySetting.getSubMchid()); + request.setDescription(ORDER_CODE_DESCRIPTION); + request.setOutTradeNo(dto.getOutTradeNo()); + request.setTimeExpire(formatWechatTime(expireTime)); + request.setNotifyUrl(wechatConfig.getBaseCallbackUrl() + wechatConfig.getPayCallbackUrl()); + request.setAmount(amount); + payOrder.setPayRep(JSONUtil.toJsonStr(request)); + + PrepayResponse response; + try { + response = nativePayService.prepay(request); + } catch (ServiceException exception) { + String errorCode = StrUtil.blankToDefault(exception.getErrorCode(), "UNKNOWN"); + saveFailedOrder(payOrder, errorCode, exception.getClass().getSimpleName()); + log.error("|{}|创建微信订单码失败,payNo:{},errorCode:{}", TAG, dto.getOutTradeNo(), errorCode); + throw new RuntimeException("创建微信订单码失败,微信错误码:" + errorCode); + } catch (Exception exception) { + saveFailedOrder(payOrder, "SDK_EXCEPTION", exception.getClass().getSimpleName()); + log.error("|{}|创建微信订单码异常,payNo:{},exceptionType:{}", TAG, dto.getOutTradeNo(), exception.getClass().getSimpleName()); + throw new RuntimeException("创建微信订单码失败,请稍后重试"); + } + + if (response == null || StrUtil.isBlank(response.getCodeUrl())) { + saveFailedOrder(payOrder, response == null ? "EMPTY_RESPONSE" : "EMPTY_CODE_URL", null); + throw new RuntimeException("创建微信订单码失败,微信未返回二维码"); + } + + JSONObject responseSnapshot = JSONUtil.createObj().set("qrCode", response.getCodeUrl()); + payOrder.setPayRes(responseSnapshot.toString()); + thirdPayOrderService.save(payOrder); + + ThirdOrderCodePayVo result = new ThirdOrderCodePayVo(); + result.setPayNo(dto.getOutTradeNo()); + result.setQrCode(response.getCodeUrl()); + result.setExpireTime(expireTime); + return result; + } + + /** + * 使用订单配置快照中的子商户号查询微信 Native 订单。 + * + * @param payNo 第三方支付服务生成的商户订单号 + * @param settingJson 创建订单时保存的子商户支付配置快照 + * @return 微信订单状态;调用异常时返回 {@code null} + */ + @Override + public Transaction query(String payNo, String settingJson) { + try { + MchPaySettingEntity.WechatPaySetting setting = parseWechatPaySettingSnapshot(settingJson); + QueryOrderByOutTradeNoRequest request = new QueryOrderByOutTradeNoRequest(); + request.setOutTradeNo(payNo); + request.setSpMchid(appPlusPayConfig.getWechatConfig().getMerchantId()); + request.setSubMchid(setting.getSubMchid()); + return nativePayService.queryOrderByOutTradeNo(request); + } catch (ServiceException exception) { + log.warn("|{}|查询微信订单码失败,payNo:{},errorCode:{}", TAG, payNo, + StrUtil.blankToDefault(exception.getErrorCode(), "UNKNOWN")); + } catch (Exception exception) { + log.error("|{}|查询微信订单码异常,payNo:{},exceptionType:{}", TAG, payNo, exception.getClass().getSimpleName()); + } + return null; + } + + /** + * 使用订单配置快照中的子商户号关闭微信 Native 订单。 + * + * @param payNo 第三方支付服务生成的商户订单号 + * @param settingJson 创建订单时保存的子商户支付配置快照 + * @return SDK 无异常完成关单时返回 {@code true} + */ + @Override + public boolean close(String payNo, String settingJson) { + try { + MchPaySettingEntity.WechatPaySetting setting = parseWechatPaySettingSnapshot(settingJson); + CloseOrderRequest request = new CloseOrderRequest(); + request.setOutTradeNo(payNo); + request.setSpMchid(appPlusPayConfig.getWechatConfig().getMerchantId()); + request.setSubMchid(setting.getSubMchid()); + nativePayService.closeOrder(request); + return true; + } catch (ServiceException exception) { + log.warn("|{}|关闭微信订单码失败,payNo:{},errorCode:{}", TAG, payNo, + StrUtil.blankToDefault(exception.getErrorCode(), "UNKNOWN")); + } catch (Exception exception) { + log.error("|{}|关闭微信订单码异常,payNo:{},exceptionType:{}", TAG, payNo, exception.getClass().getSimpleName()); + } + return false; + } + + /** + * 构建完整的微信订单码待支付记录,沿用现有订单表字段而不扩展数据库结构。 + * + * @param dto 内部支付参数 + * @param setting 子商户微信支付配置 + * @param expireTime 服务端二维码过期时间 + * @return 可直接持久化的待支付订单 + */ + private ThirdPayOrderEntity buildOrderCodePayOrder(ThirdAppPlusPayDto dto, MchPaySettingVo setting, LocalDateTime expireTime) { + ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity(); + payOrder.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType()); + payOrder.setBizId(dto.getBizId()); + payOrder.setBizType(dto.getBizType().getType()); + payOrder.setUserId(dto.getUserId()); + payOrder.setPrice(dto.getAmount()); + payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType()); + payOrder.setPayNo(dto.getOutTradeNo()); + payOrder.setMchId(dto.getMchId()); + payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting)); + payOrder.setExpireTime(expireTime); + return payOrder; + } + + /** + * 将预创建失败原因保存为精简快照,不持久化 SDK 原始响应或敏感配置。 + * + * @param payOrder 待保存的内部支付订单 + * @param errorCode 微信错误码或本地错误标识 + * @param exceptionType 异常类型;无异常对象时可为空 + */ + private void saveFailedOrder(ThirdPayOrderEntity payOrder, String errorCode, String exceptionType) { + JSONObject failureSnapshot = JSONUtil.createObj().set("code", errorCode); + if (StrUtil.isNotBlank(exceptionType)) { + failureSnapshot.set("exceptionType", exceptionType); + } + payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_FAILED.getType()); + payOrder.setPayRes(failureSnapshot.toString()); + thirdPayOrderService.save(payOrder); + } + + /** + * 校验并解析当前子商户微信支付配置。 + * + * @param setting 当前商户启用的微信支付配置 + * @return 包含非空子商户号的微信配置 + */ + private MchPaySettingEntity.WechatPaySetting parseWechatPaySetting(MchPaySettingVo setting) { + if (setting == null || StrUtil.isBlank(setting.getSettingJson())) { + throw new RuntimeException("商户微信支付配置不完整"); + } + MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class); + if (wechatPaySetting == null || StrUtil.isBlank(wechatPaySetting.getSubMchid())) { + throw new RuntimeException("商户微信子商户号未配置"); + } + return wechatPaySetting; + } + + /** + * 从支付订单保存的配置快照中解析子商户微信支付配置。 + * + * @param settingJson 创建支付订单时保存的商户配置快照 + * @return 包含非空子商户号的微信配置 + */ + private MchPaySettingEntity.WechatPaySetting parseWechatPaySettingSnapshot(String settingJson) { + if (StrUtil.isBlank(settingJson)) { + throw new RuntimeException("支付订单缺少商户配置快照"); + } + MchPaySettingEntity settingSnapshot = JSONUtil.toBean(settingJson, MchPaySettingEntity.class); + if (settingSnapshot == null) { + throw new RuntimeException("支付订单商户配置快照无效"); + } + MchPaySettingVo setting = new MchPaySettingVo(); + setting.setSettingJson(settingSnapshot.getSettingJson()); + return parseWechatPaySetting(setting); + } + + /** + * 将本地时间按微信支付要求转换为带时区且精确到秒的 RFC3339 字符串。 + * + * @param expireTime 服务端订单码过期时间 + * @return 微信支付 {@code time_expire} 参数 + */ + private String formatWechatTime(LocalDateTime expireTime) { + return expireTime.withNano(0).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImpl.java index 863571ab6..6e791f361 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImpl.java @@ -7,14 +7,19 @@ import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.codec.Base64; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.RandomUtil; +import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.XmlUtil; import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.*; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService; import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatService; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; import com.bsd.pjcloud.thirdparty.service.impl.appplus.fz.lifecycle.ThirdAppPlusWechatFzLifecycle; @@ -27,13 +32,13 @@ import com.wechat.pay.java.core.notification.RequestParam; import com.wechat.pay.java.service.partnerpayments.app.AppServiceExtension; import com.wechat.pay.java.service.partnerpayments.app.model.*; import com.wechat.pay.java.service.partnerpayments.app.model.Amount; -import com.wechat.pay.java.service.partnerpayments.nativepay.NativePayService; import com.wechat.pay.java.service.profitsharing.ProfitsharingService; import com.wechat.pay.java.service.refund.RefundService; import com.wechat.pay.java.service.refund.model.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.codec.digest.DigestUtils; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; @@ -71,6 +76,14 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService private final ThirdAppPlusWechatFzLifecycle appPlusWechatFzLifecycle; + /** + * 聚合会话服务只用于共享回调严格区分 JSAPI 聚合子订单和旧 Native/App 订单。 + * + *

设为可选注入以保持现有独立单元测试构造方式;生产 Spring 容器会提供该 Bean。

+ */ + @Autowired(required = false) + private ThirdAggregatePaySessionService aggregatePaySessionService; + private static final ExecutorService executorService = Executors.newFixedThreadPool(10); /** @@ -277,62 +290,83 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService } /** - * 支付的回调通知 + * 验签、解密并校验微信支付成功回调,使用未支付条件原子推进内部订单。 + * + * @param body 微信支付回调原始请求体 + * @param headers 微信支付签名请求头 + * @return 已支付的最新内部订单;验签、字段校验或状态推进失败时返回 {@code null} */ @Override public ThirdPayOrderEntity payCallback(String body, Map headers) { Transaction transaction = pasePayCallback(body, headers); if (ObjectUtil.isEmpty(transaction) || ObjectUtil.isEmpty(transaction.getOutTradeNo())) { - log.error("|{}|payCallback,意外的错误,微信支付回调解析错误", TAG); + log.warn("|{}|微信支付回调解析或验签失败", TAG); return null; } ThirdPayOrderEntity payOrderEntity = thirdPayOrderService.getByPayNo(transaction.getOutTradeNo()); if (ObjectUtil.isEmpty(payOrderEntity)) { - log.error("|{}|payCallback,意外的错误,订单不存在,微信返回body|{}|", TAG, body); + log.warn("|{}|微信支付回调订单不存在,payNo:{}", TAG, transaction.getOutTradeNo()); return null; } - // 重复的回调 + ThirdAggregatePaySessionEntity aggregateSession = aggregatePaySessionService == null + ? null : aggregatePaySessionService.getByPayNo(payOrderEntity.getPayNo()); + if (ObjectUtil.notEqual(payOrderEntity.getPayPlatformType(), AppPlusPlatformTypeEnum.WECHAT.getType()) + || !validateWechatPayCallback(transaction, payOrderEntity, aggregateSession)) { + return null; + } + if (ObjectUtil.notEqual(Transaction.TradeStateEnum.SUCCESS, transaction.getTradeState())) { + log.warn("|{}|微信支付成功回调状态无效,payNo:{},tradeState:{}", TAG, + transaction.getOutTradeNo(), transaction.getTradeState()); + return null; + } + // 微信可能重复通知;已经成功的订单直接返回,由上层继续确认业务回调标识。 + if (ObjectUtil.equals(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) { + return payOrderEntity; + } if (ObjectUtil.notEqual(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) { - log.error("|{}|payCallback,意外的错误,重复的回调,微信返回body|{}|", TAG, body); return null; } - boolean paySucess = ObjectUtil.equals(Transaction.TradeStateEnum.SUCCESS, transaction.getTradeState()); - // 更新订单状态 ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity(); updateObj.setThirdPayNo(transaction.getTransactionId()); - updateObj.setId(payOrderEntity.getId()); updateObj.setPayCallbackRes(JSONUtil.toJsonStr(transaction)); - updateObj.setIsBizCallback(YesNoEnum.NO.getCode()); - updateObj.setStatus(paySucess ? ThirdPayOrderStatusEnum.PAY_SUCCESS.getType() : ThirdPayOrderStatusEnum.PAY_FAILED.getType()); - thirdPayOrderService.updateById(updateObj); + // 聚合 JSAPI 子订单只同步聚合会话,不进入旧业务回调链。 + if (aggregateSession == null) { + updateObj.setIsBizCallback(YesNoEnum.NO.getCode()); + } + updateObj.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); + updateObj.setPayTime(LocalDateTime.now()); + boolean stateChanged = thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, payOrderEntity.getId()) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType())); ThirdPayOrderEntity result = thirdPayOrderService.getById(payOrderEntity.getId()); - // 订单支付成功 - CompletableFuture.runAsync(() -> { - try { - Thread.sleep(30000); - appPlusWechatFzLifecycle.orderPaySuccess(thirdPayOrderService.getById(payOrderEntity.getId())); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.error("|{}|延迟调用orderPaySuccess被中断", TAG, e); - } catch (Exception e) { - log.error("|{}|延迟调用orderPaySuccess异常", TAG, e); - } - }, executorService); + if (result == null || ObjectUtil.notEqual(result.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) { + return null; + } + boolean orderCodePay = ObjectUtil.equals(payOrderEntity.getBizType(), ThirdPayOrderBizTypeEnum.YXFJ_PAY.getType()) + && payOrderEntity.getExpireTime() != null; + // 订单码不参与原有 App 充值分账生命周期,避免无分账配置的消费机订单触发延迟任务。 + if (stateChanged && !orderCodePay) { + schedulePaySuccessLifecycle(payOrderEntity.getId()); + } return result; } /** - * 解析支付的回调 + * 使用微信 SDK 验证回调签名并解密交易资源。 + * + * @param body 微信支付回调原始请求体 + * @param headers 微信支付签名请求头 + * @return 解密后的交易信息;验签或解密失败时返回 {@code null} */ @Override public Transaction pasePayCallback(String body, Map headers) { - String serialNumber = headers.get("Wechatpay-Serial"); - String nonce = headers.get("Wechatpay-Nonce"); - String signature = headers.get("Wechatpay-Signature"); - String signatureType = headers.get("Wechatpay-Signature-Type"); - String timestamp = headers.get("Wechatpay-Timestamp"); + String serialNumber = getHeaderIgnoreCase(headers, "Wechatpay-Serial"); + String nonce = getHeaderIgnoreCase(headers, "Wechatpay-Nonce"); + String signature = getHeaderIgnoreCase(headers, "Wechatpay-Signature"); + String signatureType = getHeaderIgnoreCase(headers, "Wechatpay-Signature-Type"); + String timestamp = getHeaderIgnoreCase(headers, "Wechatpay-Timestamp"); if (!ObjectUtil.isAllNotEmpty(serialNumber, nonce, signature, timestamp)) { return null; } @@ -347,10 +381,172 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService try { return notificationParser.parse(requestParam, Transaction.class); } catch (Exception e) { + log.warn("|{}|微信支付回调验签或解密异常,exceptionType:{}", TAG, e.getClass().getSimpleName()); return null; } } + /** + * 校验微信支付回调中的应用、服务商、子商户、金额及订单码交易类型。 + * + * @param transaction 微信验签并解密后的交易数据 + * @param payOrder 本地支付订单及其创建时配置快照 + * @return 所有关键字段与本地订单一致时返回 {@code true} + */ + private boolean validateWechatPayCallback(Transaction transaction, ThirdPayOrderEntity payOrder, + ThirdAggregatePaySessionEntity aggregateSession) { + AppPlusPayConfig.WechatConfig wechatConfig = appPlusPayConfig.getWechatConfig(); + if (wechatConfig == null || ObjectUtil.notEqual(transaction.getSpMchid(), wechatConfig.getMerchantId())) { + log.warn("|{}|微信支付回调服务商信息不一致,payNo:{}", TAG, payOrder.getPayNo()); + return false; + } + MchPaySettingEntity.WechatPaySetting wechatPaySetting = parseWechatPaySettingSnapshot(payOrder.getMchPaySettingJson()); + if (wechatPaySetting == null || ObjectUtil.notEqual(transaction.getSubMchid(), wechatPaySetting.getSubMchid())) { + log.warn("|{}|微信支付回调子商户号不一致,payNo:{}", TAG, payOrder.getPayNo()); + return false; + } + if (transaction.getAmount() == null || ObjectUtil.notEqual(transaction.getAmount().getTotal(), payOrder.getPrice())) { + log.warn("|{}|微信支付回调金额不一致,payNo:{}", TAG, payOrder.getPayNo()); + return false; + } + boolean aggregateRequest = isAggregatePayRequest(payOrder); + if (aggregateRequest != (aggregateSession != null)) { + log.warn("|{}|微信聚合支付回调会话绑定不一致,payNo:{}", TAG, payOrder.getPayNo()); + return false; + } + if (aggregateSession != null) { + String spAppid = getAggregateSpAppid(payOrder); + if (!isMatchingAggregateSession(aggregateSession, payOrder) + || ObjectUtil.notEqual(aggregateSession.getSelectedPlatform(), AppPlusPlatformTypeEnum.WECHAT.getType()) + || ObjectUtil.notEqual(spAppid, wechatConfig.getGzhAppId()) + || ObjectUtil.notEqual(transaction.getSpAppid(), spAppid) + || StrUtil.isNotBlank(transaction.getSubAppid()) + || ObjectUtil.notEqual(transaction.getTradeType(), Transaction.TradeTypeEnum.JSAPI)) { + log.warn("|{}|微信聚合JSAPI回调应用或交易类型不一致,payNo:{}", TAG, payOrder.getPayNo()); + return false; + } + return true; + } + if (ObjectUtil.notEqual(transaction.getSpAppid(), wechatConfig.getAppPlatformAppId())) { + log.warn("|{}|微信旧支付回调应用不一致,payNo:{}", TAG, payOrder.getPayNo()); + return false; + } + boolean orderCodePay = ObjectUtil.equals(payOrder.getBizType(), ThirdPayOrderBizTypeEnum.YXFJ_PAY.getType()) + && payOrder.getExpireTime() != null; + if (orderCodePay && ObjectUtil.notEqual(transaction.getTradeType(), Transaction.TradeTypeEnum.NATIVE)) { + log.warn("|{}|微信订单码回调交易类型不一致,payNo:{},tradeType:{}", TAG, + payOrder.getPayNo(), transaction.getTradeType()); + return false; + } + return true; + } + + /** + * 校验聚合会话与微信 JSAPI 子订单的业务、商户和金额快照一致。 + * + * @param session {@code pay_no} 绑定的聚合支付会话 + * @param payOrder 微信回调对应的第三方子订单 + * @return 关键业务字段完全一致时返回 {@code true} + */ + private boolean isMatchingAggregateSession(ThirdAggregatePaySessionEntity session, ThirdPayOrderEntity payOrder) { + return ObjectUtil.equals(session.getPayNo(), payOrder.getPayNo()) + && ObjectUtil.equals(session.getBizType(), payOrder.getBizType()) + && ObjectUtil.equals(session.getBizId(), payOrder.getBizId()) + && ObjectUtil.equals(session.getMchId(), payOrder.getMchId()) + && ObjectUtil.equals(session.getPrice(), payOrder.getPrice()); + } + + /** + * 判断内部订单请求快照是否明确标记为聚合 JSAPI 子订单。 + * + * @param payOrder 回调商户订单号对应的内部支付订单 + * @return 请求快照同时包含聚合标记和 JSAPI 交易类型时返回 {@code true} + */ + private boolean isAggregatePayRequest(ThirdPayOrderEntity payOrder) { + if (payOrder == null || StrUtil.isBlank(payOrder.getPayRep())) { + return false; + } + try { + cn.hutool.json.JSONObject request = JSONUtil.parseObj(payOrder.getPayRep()); + return Boolean.TRUE.equals(request.getBool("aggregatePay")) + && ObjectUtil.equals(request.getStr("tradeType"), "JSAPI"); + } catch (Exception exception) { + return false; + } + } + + /** + * 从聚合子订单安全请求快照中读取创建时使用的服务商公众号 sp_appid。 + * + * @param payOrder 已确认带聚合 JSAPI 标记的内部支付订单 + * @return 创建时的服务商公众号 AppID;快照无效时返回 {@code null} + */ + private String getAggregateSpAppid(ThirdPayOrderEntity payOrder) { + try { + return JSONUtil.parseObj(payOrder.getPayRep()).getStr("spAppid"); + } catch (Exception exception) { + return null; + } + } + + /** + * 从内部支付订单的商户配置快照解析微信子商户号;快照无效时返回空。 + * + * @param settingJson 创建支付订单时保存的商户配置快照 + * @return 微信子商户配置;快照为空或解析失败时返回 {@code null} + */ + private MchPaySettingEntity.WechatPaySetting parseWechatPaySettingSnapshot(String settingJson) { + if (StrUtil.isBlank(settingJson)) { + return null; + } + try { + MchPaySettingEntity setting = JSONUtil.toBean(settingJson, MchPaySettingEntity.class); + if (setting == null || StrUtil.isBlank(setting.getSettingJson())) { + return null; + } + return JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class); + } catch (Exception exception) { + return null; + } + } + + /** + * 异步延迟触发现有微信分账生命周期,只在支付状态首次推进成功时调度一次。 + * + * @param payOrderId 首次确认支付成功的内部订单主键 + */ + private void schedulePaySuccessLifecycle(Long payOrderId) { + CompletableFuture.runAsync(() -> { + try { + Thread.sleep(30000); + appPlusWechatFzLifecycle.orderPaySuccess(thirdPayOrderService.getById(payOrderId)); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + log.error("|{}|延迟调用orderPaySuccess被中断", TAG, exception); + } catch (Exception exception) { + log.error("|{}|延迟调用orderPaySuccess异常", TAG, exception); + } + }, executorService); + } + + /** + * 以大小写无关方式读取微信回调请求头,兼容 Servlet 容器对 Header 名称的规范化。 + * + * @param headers 原始回调请求头 + * @param headerName 微信支付文档定义的请求头名称 + * @return 请求头值;不存在时返回 {@code null} + */ + private String getHeaderIgnoreCase(Map headers, String headerName) { + if (headers == null || StrUtil.isBlank(headerName)) { + return null; + } + return headers.entrySet().stream() + .filter(entry -> headerName.equalsIgnoreCase(entry.getKey())) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + } + /** * 退款回调 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java new file mode 100644 index 000000000..c7d4bd1e5 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java @@ -0,0 +1,207 @@ +package com.bsd.pjcloud.thirdparty.service.impl.pay; + +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdAggregatePaySessionMapper; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 聚合订单码支付会话持久化服务实现。 + */ +@Service +@Validated +public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl + implements ThirdAggregatePaySessionService { + + /** + * 按聚合会话编号读取当前租户有效记录。 + * + * @param aggregateNo 聚合支付会话编号 + * @return 匹配会话;参数为空或记录不存在时返回 {@code null} + */ + @Override + public ThirdAggregatePaySessionEntity getByAggregateNo(String aggregateNo) { + if (StrUtil.isBlank(aggregateNo)) { + return null; + } + return getOne(new LambdaQueryWrapper() + .eq(ThirdAggregatePaySessionEntity::getAggregateNo, aggregateNo)); + } + + /** + * 按访问令牌摘要读取当前租户有效记录。 + * + * @param accessTokenHash 原始随机令牌的 SHA-256 摘要 + * @return 匹配会话;参数为空或记录不存在时返回 {@code null} + */ + @Override + public ThirdAggregatePaySessionEntity getByAccessTokenHash(String accessTokenHash) { + if (StrUtil.isBlank(accessTokenHash)) { + return null; + } + return getOne(new LambdaQueryWrapper() + .eq(ThirdAggregatePaySessionEntity::getAccessTokenHash, accessTokenHash)); + } + + /** + * 按第三方子订单号读取当前租户绑定会话。 + * + * @param payNo {@code third_pay_order.pay_no} + * @return 绑定会话;参数为空或不是聚合订单时返回 {@code null} + */ + @Override + public ThirdAggregatePaySessionEntity getByPayNo(String payNo) { + if (StrUtil.isBlank(payNo)) { + return null; + } + return getOne(new LambdaQueryWrapper() + .eq(ThirdAggregatePaySessionEntity::getPayNo, payNo)); + } + + /** + * 查询同业务最新聚合会话,供创建幂等和跨二维码互斥使用。 + * + * @param bizType 支付业务类型 + * @param bizId 调用方业务订单唯一标识 + * @return 按创建时间倒序的第一条记录;参数无效或不存在时返回 {@code null} + */ + @Override + public ThirdAggregatePaySessionEntity getLatestByBiz(ThirdPayOrderBizTypeEnum bizType, String bizId) { + if (bizType == null || StrUtil.isBlank(bizId)) { + return null; + } + return getOne(new LambdaQueryWrapper() + .eq(ThirdAggregatePaySessionEntity::getBizType, bizType.getType()) + .eq(ThirdAggregatePaySessionEntity::getBizId, bizId) + .orderByDesc(ThirdAggregatePaySessionEntity::getCreateTime) + .last("limit 1")); + } + + /** + * 使用等待扫码状态作为 CAS 条件锁定平台和付款人,防止支付宝、微信同时创建可支付订单。 + * + * @param sessionId 聚合会话主键 + * @param platform 首个扫码选定的平台 + * @param payNo 预分配的第三方子订单号 + * @param payerIdentityHash 付款人身份摘要 + * @return 仅一个竞争请求会返回 {@code true} + */ + @Override + public boolean selectPlatform(Long sessionId, AppPlusPlatformTypeEnum platform, String payNo, String payerIdentityHash) { + if (sessionId == null || platform == null || StrUtil.hasBlank(payNo, payerIdentityHash)) { + return false; + } + ThirdAggregatePaySessionEntity update = new ThirdAggregatePaySessionEntity(); + update.setStatus(ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType()); + update.setSelectedPlatform(platform.getType()); + update.setPayNo(payNo); + update.setPayerIdentityHash(payerIdentityHash); + return update(update, new LambdaUpdateWrapper() + .eq(ThirdAggregatePaySessionEntity::getId, sessionId) + .eq(ThirdAggregatePaySessionEntity::getStatus, ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType()) + .isNull(ThirdAggregatePaySessionEntity::getSelectedPlatform) + .isNull(ThirdAggregatePaySessionEntity::getPayNo)); + } + + /** + * 在第三方子订单保存成功后把会话从创建中推进为支付中。 + * + * @param sessionId 聚合会话主键 + * @param payNo 已成功保存的第三方子订单号 + * @return CAS 更新成功时返回 {@code true} + */ + @Override + public boolean markPaying(Long sessionId, String payNo) { + ThirdAggregatePaySessionEntity update = new ThirdAggregatePaySessionEntity(); + update.setStatus(ThirdAggregatePaySessionStatusEnum.PAYING.getType()); + return update(update, new LambdaUpdateWrapper() + .eq(ThirdAggregatePaySessionEntity::getId, sessionId) + .eq(ThirdAggregatePaySessionEntity::getPayNo, payNo) + .eq(ThirdAggregatePaySessionEntity::getStatus, ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType())); + } + + /** + * 将明确失败的平台创建过程推进为创建失败终态。 + * + * @param sessionId 聚合会话主键 + * @param payNo 创建失败但已预分配的第三方子订单号 + * @return CAS 更新成功时返回 {@code true} + */ + @Override + public boolean markCreateFailed(Long sessionId, String payNo) { + ThirdAggregatePaySessionEntity update = new ThirdAggregatePaySessionEntity(); + update.setStatus(ThirdAggregatePaySessionStatusEnum.CREATE_FAILED.getType()); + update.setResolvedTime(LocalDateTime.now()); + return update(update, new LambdaUpdateWrapper() + .eq(ThirdAggregatePaySessionEntity::getId, sessionId) + .eq(ThirdAggregatePaySessionEntity::getPayNo, payNo) + .eq(ThirdAggregatePaySessionEntity::getStatus, ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType())); + } + + /** + * 将尚未进入终态的会话推进为成功、关闭或过期状态。 + * + * @param sessionId 聚合会话主键 + * @param status 目标终态 + * @return CAS 更新成功时返回 {@code true} + */ + @Override + public boolean markTerminal(Long sessionId, ThirdAggregatePaySessionStatusEnum status) { + if (sessionId == null || status == null || !List.of( + ThirdAggregatePaySessionStatusEnum.PAY_SUCCESS, + ThirdAggregatePaySessionStatusEnum.PAY_CLOSED, + ThirdAggregatePaySessionStatusEnum.EXPIRED).contains(status)) { + return false; + } + ThirdAggregatePaySessionEntity update = new ThirdAggregatePaySessionEntity(); + update.setStatus(status.getType()); + update.setResolvedTime(LocalDateTime.now()); + return update(update, new LambdaUpdateWrapper() + .eq(ThirdAggregatePaySessionEntity::getId, sessionId) + .in(ThirdAggregatePaySessionEntity::getStatus, + ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(), + ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(), + ThirdAggregatePaySessionStatusEnum.PAYING.getType())); + } + + /** + * 根据第三方支付订单终态更新绑定会话,重复回调和定时查询不会覆盖既有终态。 + * + * @param payOrder 最新第三方支付子订单 + */ + @Override + public void syncFromPayOrder(ThirdPayOrderEntity payOrder) { + if (payOrder == null || StrUtil.isBlank(payOrder.getPayNo())) { + return; + } + ThirdAggregatePaySessionEntity session = getByPayNo(payOrder.getPayNo()); + if (session == null) { + return; + } + if (ObjectUtil.equals(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) { + markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_SUCCESS); + } else if (ObjectUtil.equals(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_CLOSED.getType())) { + markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED); + } else if (ObjectUtil.equals(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_FAILED.getType())) { + if (ObjectUtil.equals(session.getStatus(), ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType())) { + markCreateFailed(session.getId(), payOrder.getPayNo()); + } else { + markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED); + } + } + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJobTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJobTest.java new file mode 100644 index 000000000..a5d2332a4 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJobTest.java @@ -0,0 +1,59 @@ +package com.bsd.pjcloud.thirdparty.job; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusService; +import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ThirdAppPlusPayJobTest { + + @Mock + private ThirdPayOrderService thirdPayOrderService; + + @Mock + private ThirdAppPlusService thirdAppPlusService; + + @InjectMocks + private ThirdAppPlusPayJob job; + + @AfterEach + void tearDown() { + TenantContextHolder.setTenantId(null); + } + + @Test + void handleOrderCodeOrdersShouldRestoreEachOrderTenant() { + ThirdPayOrderEntity order = new ThirdPayOrderEntity(); + order.setId(99L); + order.setTenantId(66L); + order.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType()); + order.setExpireTime(LocalDateTime.now().plusMinutes(1)); + when(thirdPayOrderService.list(any(Wrapper.class))).thenReturn(List.of(order)); + when(thirdPayOrderService.getById(order.getId())).thenAnswer(invocation -> { + Assertions.assertEquals(66L, TenantContextHolder.getTenantId()); + return order; + }); + + job.handleOrderCodeOrders(); + + verify(thirdAppPlusService).queryPayResult(order); + Assertions.assertNull(TenantContextHolder.getTenantId()); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateCashierHtmlRendererTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateCashierHtmlRendererTest.java new file mode 100644 index 000000000..ac4186a27 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateCashierHtmlRendererTest.java @@ -0,0 +1,63 @@ +package com.bsd.pjcloud.thirdparty.service; + +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierPageData; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +/** + * 聚合收银 HTML 渲染器的支付宝 JSBridge 调起脚本测试。 + */ +class ThirdAggregateCashierHtmlRendererTest { + + /** + * 被测固定 HTML 渲染器,不依赖 Spring 容器。 + */ + private final ThirdAggregateCashierHtmlRenderer renderer = new ThirdAggregateCashierHtmlRenderer(); + + /** + * 验证支付宝收银页严格提取 {@code tradeNO},等待桥接注入并保留真实点击重试入口。 + */ + @Test + void renderAlipayPageShouldInvokeBridgeWithTradeNoAndProvideRetry() { + String html = renderer.renderPayPage(alipayPageData(), "test-nonce"); + + Assertions.assertTrue(html.contains("nonce=\"test-nonce\"")); + Assertions.assertTrue(html.contains("id=\"retryButton\"")); + Assertions.assertTrue(html.contains("window.AlipayJSBridge")); + Assertions.assertTrue(html.contains("bridge.call('tradePay',{tradeNO:tradeNO},handleAlipayResult)")); + Assertions.assertTrue(html.contains("document.addEventListener('AlipayJSBridgeReady',invokePay,false)")); + Assertions.assertTrue(html.contains("const statusUrl=\"https://pay.example.com/third/aggregate-order-pay/status/tenant-code/test-ticket\";")); + Assertions.assertFalse(html.contains("AlipayJSBridge.call('tradePay',payParams")); + } + + /** + * 验证支付宝官方成功、处理中、网络异常、取消和失败结果码均有明确分支,且不会直接认定服务端支付成功。 + */ + @Test + void renderAlipayPageShouldHandleOfficialResultCodesAndKeepPolling() { + String html = renderer.renderPayPage(alipayPageData(), "test-nonce"); + + Assertions.assertTrue(html.contains("resultCode==='9000'")); + Assertions.assertTrue(html.contains("resultCode==='8000'||resultCode==='6004'")); + Assertions.assertTrue(html.contains("resultCode==='6001'")); + Assertions.assertTrue(html.contains("resultCode==='6002'")); + Assertions.assertTrue(html.contains("resultCode==='4000'")); + Assertions.assertTrue(html.contains("setTimeout(startPolling,1000)")); + } + + /** + * 构建只含脱敏测试交易号和本地状态地址的支付宝收银页数据。 + * + * @return 支付宝聚合收银页测试数据 + */ + private ThirdAggregateCashierPageData alipayPageData() { + ThirdAggregateCashierLaunch launch = new ThirdAggregateCashierLaunch(AppPlusPlatformTypeEnum.ALI_PAY, + "test-pay-no", Map.of("tradeNO", "test-alipay-trade-no")); + return new ThirdAggregateCashierPageData(launch, + "https://pay.example.com/third/aggregate-order-pay/status/tenant-code/test-ticket"); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java new file mode 100644 index 000000000..242ec8310 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java @@ -0,0 +1,217 @@ +package com.bsd.pjcloud.thirdparty.service.impl.appplus; + +import cn.hutool.json.JSONUtil; +import com.alipay.api.AlipayClient; +import com.alipay.api.domain.AlipayTradeCreateModel; +import com.alipay.api.request.AlipaySystemOauthTokenRequest; +import com.alipay.api.request.AlipayTradeCreateRequest; +import com.alipay.api.response.AlipaySystemOauthTokenResponse; +import com.alipay.api.response.AlipayTradeCreateResponse; +import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateAlipayOauthIdentity; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext; +import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService; +import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 支付宝聚合 OAuth 与服务商 JSAPI 子订单参数测试。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdAggregateAliPayServiceImplTest { + + /** + * 支付宝 SDK 客户端 Mock,用于捕获 OAuth 和交易创建请求。 + */ + @Mock + private AlipayClient alipayClient; + + /** + * 第三方支付订单服务 Mock,用于验证子订单快照和保存失败补偿。 + */ + @Mock + private ThirdPayOrderService thirdPayOrderService; + + /** + * 旧支付宝服务 Mock,用于验证聚合子订单保存失败后的最佳努力撤销。 + */ + @Mock + private ThirdAppPlusAliPayService thirdAppPlusAliPayService; + + /** + * 被测支付宝聚合服务,通过子类固定返回 Mock SDK 客户端。 + */ + private ThirdAggregateAliPayServiceImpl service; + + /** + * 当前测试使用的聚合子订单上下文,金额单位为人民币分。 + */ + private ThirdAggregatePayCreateContext context; + + /** + * 当前测试使用的支付宝商户授权配置快照。 + */ + private MchPaySettingVo paySetting; + + /** + * 初始化应用配置、支付上下文和商户授权 Token。 + */ + @BeforeEach + void setUp() { + AppPlusPayConfig appPlusPayConfig = new AppPlusPayConfig(); + AppPlusPayConfig.AliPayConfig aliPayConfig = new AppPlusPayConfig.AliPayConfig(); + aliPayConfig.setAppId("2026000000000001"); + aliPayConfig.setAppPrivateKey("test-private-key"); + aliPayConfig.setAlipayPublicKey("test-public-key"); + aliPayConfig.setBaseCallbackUrl("https://pay.example.com"); + aliPayConfig.setPayCallbackUrl("/third-app-pay/app/pay-callback"); + appPlusPayConfig.setAliPayConfig(aliPayConfig); + service = new TestableThirdAggregateAliPayService(appPlusPayConfig, thirdPayOrderService, + thirdAppPlusAliPayService, alipayClient); + + context = new ThirdAggregatePayCreateContext(); + context.setPayNo("AGP202608050001"); + context.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY); + context.setBizId("order-1001"); + context.setUserId(-1L); + context.setMchId(88L); + context.setAmount(1234); + context.setExpireTime(LocalDateTime.now().plusMinutes(2)); + + MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting(); + aliPaySetting.setAppAuthToken("merchant-auth-token"); + paySetting = new MchPaySettingVo(); + paySetting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); + } + + /** + * 验证静默 OAuth 授权码只换取稳定付款人身份,不持久化访问令牌。 + * + * @throws Exception Mock 支付宝客户端声明的受检异常 + */ + @Test + void exchangeOauthCodeShouldReturnOpenId() throws Exception { + AlipaySystemOauthTokenResponse response = new AlipaySystemOauthTokenResponse(); + response.setCode("10000"); + response.setOpenId("alipay-open-id"); + when(alipayClient.execute(any(AlipaySystemOauthTokenRequest.class))).thenReturn(response); + + ThirdAggregateAlipayOauthIdentity identity = service.exchangeOauthCode("one-time-auth-code"); + + Assertions.assertEquals("alipay-open-id", identity.getStableIdentity()); + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(AlipaySystemOauthTokenRequest.class); + verify(alipayClient).execute(requestCaptor.capture()); + Assertions.assertEquals("authorization_code", requestCaptor.getValue().getGrantType()); + Assertions.assertEquals("one-time-auth-code", requestCaptor.getValue().getCode()); + } + + /** + * 验证 trade.create 使用 JSAPI_PAY、分转元、付款人 open_id、授权 Token 和统一过期时间。 + * + * @throws Exception Mock 支付宝客户端声明的受检异常 + */ + @Test + void createPayShouldSetJsapiParametersAndSaveUnpaidOrder() throws Exception { + AlipayTradeCreateResponse response = new AlipayTradeCreateResponse(); + response.setCode("10000"); + response.setTradeNo("2026000000000002"); + when(alipayClient.execute(any(AlipayTradeCreateRequest.class))).thenReturn(response); + when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(true); + + ThirdAggregateCashierLaunch result = service.createPay(context, paySetting, + new ThirdAggregateAlipayOauthIdentity("alipay-open-id", null)); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class); + verify(alipayClient).execute(requestCaptor.capture()); + AlipayTradeCreateRequest request = requestCaptor.getValue(); + AlipayTradeCreateModel model = (AlipayTradeCreateModel) request.getBizModel(); + Assertions.assertEquals("JSAPI_PAY", model.getProductCode()); + Assertions.assertEquals("12.34", model.getTotalAmount()); + Assertions.assertEquals("alipay-open-id", model.getBuyerOpenId()); + Assertions.assertEquals("merchant-auth-token", request.getTextParams().get("app_auth_token")); + Assertions.assertEquals("2026000000000002", result.getPayParams().get("tradeNO")); + + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).save(orderCaptor.capture()); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_UNPAID.getType(), orderCaptor.getValue().getStatus()); + Assertions.assertEquals(context.getExpireTime(), orderCaptor.getValue().getExpireTime()); + Assertions.assertFalse(orderCaptor.getValue().getPayRep().contains("merchant-auth-token")); + Assertions.assertFalse(orderCaptor.getValue().getPayRep().contains("alipay-open-id")); + } + + /** + * 验证平台下单成功但本地子订单保存失败时立即发起撤销,避免形成孤立可支付订单。 + * + * @throws Exception Mock 支付宝客户端声明的受检异常 + */ + @Test + void createPayShouldCancelWhenOrderPersistenceFails() throws Exception { + AlipayTradeCreateResponse response = new AlipayTradeCreateResponse(); + response.setCode("10000"); + response.setTradeNo("2026000000000002"); + when(alipayClient.execute(any(AlipayTradeCreateRequest.class))).thenReturn(response); + when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(false); + + Assertions.assertThrows(RuntimeException.class, () -> service.createPay(context, paySetting, + new ThirdAggregateAlipayOauthIdentity("alipay-open-id", null))); + + verify(thirdAppPlusAliPayService).cancel(eq(context.getPayNo()), any(String.class)); + } + + /** + * 固定使用 Mock 支付宝客户端的可测试服务实现。 + */ + private static final class TestableThirdAggregateAliPayService extends ThirdAggregateAliPayServiceImpl { + + /** + * 当前测试方法共享的支付宝 SDK 客户端 Mock。 + */ + private final AlipayClient testAlipayClient; + + /** + * 创建可替换 SDK 客户端的支付宝聚合服务。 + * + * @param appPlusPayConfig 测试应用配置 + * @param thirdPayOrderService 支付订单服务 Mock + * @param thirdAppPlusAliPayService 旧支付宝查询撤销服务 Mock + * @param testAlipayClient 支付宝 SDK 客户端 Mock + */ + private TestableThirdAggregateAliPayService(AppPlusPayConfig appPlusPayConfig, + ThirdPayOrderService thirdPayOrderService, + ThirdAppPlusAliPayService thirdAppPlusAliPayService, + AlipayClient testAlipayClient) { + super(appPlusPayConfig, thirdPayOrderService, thirdAppPlusAliPayService); + this.testAlipayClient = testAlipayClient; + } + + /** + * 返回测试注入的支付宝 SDK 客户端。 + * + * @return 支付宝 SDK 客户端 Mock + */ + @Override + protected AlipayClient getAlipayClient() { + return testAlipayClient; + } + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java new file mode 100644 index 000000000..8f5273d89 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java @@ -0,0 +1,285 @@ +package com.bsd.pjcloud.thirdparty.service.impl.appplus; + +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.bsd.pjcloud.admin.api.entity.SysTenant; +import com.bsd.pjcloud.admin.api.feign.RemoteTenantService; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateAliPayService; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateWechatPayService; +import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import org.jasypt.encryption.StringEncryptor; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 聚合订单码创建、复用、配置校验和二维码隔离测试。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdAggregateOrderPayServiceImplTest { + + /** + * 聚合会话持久化服务 Mock。 + */ + @Mock + private ThirdAggregatePaySessionService aggregatePaySessionService; + + /** + * 支付宝聚合平台服务 Mock。 + */ + @Mock + private ThirdAggregateAliPayService aggregateAliPayService; + + /** + * 微信聚合平台服务 Mock。 + */ + @Mock + private ThirdAggregateWechatPayService aggregateWechatPayService; + + /** + * 实际第三方子订单服务 Mock。 + */ + @Mock + private ThirdPayOrderService thirdPayOrderService; + + /** + * 钱包商户远程服务 Mock。 + */ + @Mock + private RemoteMchService remoteMchService; + + /** + * 租户唯一编码远程服务 Mock。 + */ + @Mock + private RemoteTenantService remoteTenantService; + + /** + * Redis 模板 Mock,用于验证不手工拼租户ID的共享业务锁。 + */ + @Mock + private StringRedisTemplate stringRedisTemplate; + + /** + * Redis 值操作 Mock,用于模拟创建锁和短期票据。 + */ + @Mock + private ValueOperations valueOperations; + + /** + * 项目统一字符串加密器 Mock,用于验证访问令牌只以摘要和密文落库。 + */ + @Mock + private StringEncryptor stringEncryptor; + + /** + * 被测聚合支付编排服务。 + */ + private ThirdAggregateOrderPayServiceImpl service; + + /** + * 当前测试使用的合法聚合创建请求。 + */ + private ThirdAggregateOrderCodePayDto request; + + /** + * 当前租户下同时启用支付宝和微信支付的收款商户。 + */ + private MchManageVo merchant; + + /** + * 初始化双平台应用配置、租户编码、Redis 锁和商户支付配置。 + */ + @BeforeEach + void setUp() { + AppPlusPayConfig appPlusPayConfig = completeApplicationConfig(); + service = new ThirdAggregateOrderPayServiceImpl(aggregatePaySessionService, aggregateAliPayService, + aggregateWechatPayService, thirdPayOrderService, remoteMchService, + remoteTenantService, appPlusPayConfig, stringRedisTemplate, stringEncryptor); + + when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(true); + + SysTenant tenant = new SysTenant(); + tenant.setId(1L); + tenant.setCode("tenant-code"); + lenient().when(remoteTenantService.list()).thenReturn(R.ok(List.of(tenant))); + + request = new ThirdAggregateOrderCodePayDto(); + request.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY); + request.setBizId("order-1001"); + request.setMchId(88L); + request.setAmount(1234); + + merchant = completeMerchant(); + } + + /** + * 验证新会话同时校验双平台配置,并只返回独立 aggregate-order-pay HTTPS 地址。 + */ + @Test + void createShouldPersistHashedTokenAndReturnAggregateQrCode() { + when(aggregatePaySessionService.getLatestByBiz(request.getBizType(), request.getBizId())).thenReturn(null); + when(thirdPayOrderService.list(any(Wrapper.class))).thenReturn(List.of()); + when(remoteMchService.getById(request.getMchId())).thenReturn(R.ok(merchant)); + when(stringEncryptor.encrypt(anyString())).thenReturn("encrypted-access-token"); + when(aggregatePaySessionService.save(any(ThirdAggregatePaySessionEntity.class))).thenReturn(true); + + ThirdAggregateOrderCodePayVo result = service.create(request); + + Assertions.assertEquals("AGGREGATE_ORDER_PAY", result.getQrCodeType()); + Assertions.assertTrue(result.getQrCode().startsWith( + "https://pay.example.com/third/aggregate-order-pay/cashier/tenant-code/")); + Assertions.assertFalse(result.getQrCode().contains("action=harQrCodeScanPay")); + ArgumentCaptor sessionCaptor = + ArgumentCaptor.forClass(ThirdAggregatePaySessionEntity.class); + verify(aggregatePaySessionService).save(sessionCaptor.capture()); + ThirdAggregatePaySessionEntity savedSession = sessionCaptor.getValue(); + Assertions.assertEquals(64, savedSession.getAccessTokenHash().length()); + Assertions.assertEquals("encrypted-access-token", savedSession.getAccessTokenCiphertext()); + Assertions.assertEquals(-1L, savedSession.getUserId()); + Assertions.assertEquals(ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(), savedSession.getStatus()); + Assertions.assertNull(savedSession.getSelectedPlatform()); + Assertions.assertNull(savedSession.getPayNo()); + } + + /** + * 验证同业务未过期会话复用原访问令牌,不再次读取商户或创建数据库记录。 + */ + @Test + void createShouldReuseUnexpiredSession() { + ThirdAggregatePaySessionEntity existing = reusableSession(); + when(aggregatePaySessionService.getLatestByBiz(request.getBizType(), request.getBizId())).thenReturn(existing); + when(stringEncryptor.decrypt(existing.getAccessTokenCiphertext())).thenReturn("restored-access-token"); + + ThirdAggregateOrderCodePayVo result = service.create(request); + + Assertions.assertEquals(existing.getAggregateNo(), result.getAggregateNo()); + Assertions.assertTrue(result.getQrCode().endsWith("/restored-access-token")); + verify(remoteMchService, never()).getById(any()); + verify(aggregatePaySessionService, never()).save(any(ThirdAggregatePaySessionEntity.class)); + } + + /** + * 验证缺少任一平台商户配置时不创建聚合会话,避免生成只能被单平台使用的伪聚合码。 + */ + @Test + void createShouldRejectMerchantWithoutWechatPaySetting() { + merchant.setPaySettingList(List.of(merchant.getPaySettingList().get(0))); + when(aggregatePaySessionService.getLatestByBiz(request.getBizType(), request.getBizId())).thenReturn(null); + when(thirdPayOrderService.list(any(Wrapper.class))).thenReturn(List.of()); + when(remoteMchService.getById(request.getMchId())).thenReturn(R.ok(merchant)); + + Assertions.assertThrows(RuntimeException.class, () -> service.create(request)); + + verify(aggregatePaySessionService, never()).save(any(ThirdAggregatePaySessionEntity.class)); + } + + /** + * 构建支付宝和微信全局应用配置,公网地址固定使用 HTTPS。 + * + * @return 满足聚合支付创建前置校验的配置 + */ + private AppPlusPayConfig completeApplicationConfig() { + AppPlusPayConfig config = new AppPlusPayConfig(); + AppPlusPayConfig.AliPayConfig ali = new AppPlusPayConfig.AliPayConfig(); + ali.setAppId("2026000000000001"); + ali.setAppPrivateKey("test-private-key"); + ali.setAlipayPublicKey("test-public-key"); + ali.setBaseCallbackUrl("https://pay.example.com"); + ali.setPayCallbackUrl("/third-app-pay/app/pay-callback"); + config.setAliPayConfig(ali); + + AppPlusPayConfig.WechatConfig wechat = new AppPlusPayConfig.WechatConfig(); + wechat.setGzhAppId("wx-service-jsapi-appid"); + wechat.setGzhAppSecret("service-public-account-secret"); + wechat.setMerchantId("1900000001"); + wechat.setMchPrivatePath("/tmp/test-private-key.pem"); + wechat.setMerchantSerialNumber("test-serial-number"); + wechat.setApiV3Key("test-api-v3-key"); + wechat.setBaseCallbackUrl("https://pay.example.com"); + wechat.setPayCallbackUrl("/third-app-pay/app/pay-callback"); + config.setWechatConfig(wechat); + return config; + } + + /** + * 构建同时启用支付宝 app_auth_token 和微信 sub_mchid 的商户。 + * + * @return 当前租户下的完整收款商户 + */ + private MchManageVo completeMerchant() { + MchPaySettingEntity.AliPaySetting aliDetail = new MchPaySettingEntity.AliPaySetting(); + aliDetail.setAppAuthToken("merchant-auth-token"); + MchPaySettingVo ali = new MchPaySettingVo(); + ali.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType()); + ali.setEnable(YesNoEnum.YES.getCode()); + ali.setSettingJson(JSONUtil.toJsonStr(aliDetail)); + + MchPaySettingEntity.WechatPaySetting wechatDetail = new MchPaySettingEntity.WechatPaySetting(); + wechatDetail.setSubMchid("1900000109"); + MchPaySettingVo wechat = new MchPaySettingVo(); + wechat.setPlatformType(AppPlusPlatformPayType.WECHAT.getType()); + wechat.setEnable(YesNoEnum.YES.getCode()); + wechat.setSettingJson(JSONUtil.toJsonStr(wechatDetail)); + + MchManageVo result = new MchManageVo(); + result.setId(88L); + result.setTenantId(1L); + result.setPaySettingList(List.of(ali, wechat)); + return result; + } + + /** + * 构建尚未扫码且未过期的可复用聚合会话。 + * + * @return 与默认请求商户、金额和业务标识一致的会话 + */ + private ThirdAggregatePaySessionEntity reusableSession() { + ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity(); + session.setId(99L); + session.setAggregateNo("AGG202608050001"); + session.setAccessTokenCiphertext("encrypted-access-token"); + session.setBizType(request.getBizType().getType()); + session.setBizId(request.getBizId()); + session.setMchId(request.getMchId()); + session.setPrice(request.getAmount()); + session.setStatus(ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType()); + session.setExpireTime(LocalDateTime.now().plusMinutes(1)); + session.setTenantId(1L); + return session; + } + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImplTest.java new file mode 100644 index 000000000..c85991ad3 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImplTest.java @@ -0,0 +1,162 @@ +package com.bsd.pjcloud.thirdparty.service.impl.appplus; + +import cn.hutool.json.JSONUtil; +import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch; +import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext; +import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import com.wechat.pay.java.service.partnerpayments.jsapi.JsapiServiceExtension; +import com.wechat.pay.java.service.partnerpayments.jsapi.model.CloseOrderRequest; +import com.wechat.pay.java.service.partnerpayments.jsapi.model.PrepayRequest; +import com.wechat.pay.java.service.partnerpayments.jsapi.model.PrepayWithRequestPaymentResponse; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 微信支付服务商聚合 JSAPI 子订单参数和失败补偿测试。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdAggregateWechatPayServiceImplTest { + + /** + * 微信服务商 JSAPI SDK Mock,用于捕获预支付和关单请求。 + */ + @Mock + private JsapiServiceExtension jsapiServiceExtension; + + /** + * 第三方支付订单服务 Mock,用于验证保存状态和保存失败补偿。 + */ + @Mock + private ThirdPayOrderService thirdPayOrderService; + + /** + * 被测微信聚合支付服务。 + */ + private ThirdAggregateWechatPayServiceImpl service; + + /** + * 聚合子订单上下文,金额单位为人民币分。 + */ + private ThirdAggregatePayCreateContext context; + + /** + * 微信支付子商户号配置快照。 + */ + private MchPaySettingVo paySetting; + + /** + * 初始化服务商统一公众号、子商户号和支付上下文。 + */ + @BeforeEach + void setUp() { + AppPlusPayConfig appPlusPayConfig = new AppPlusPayConfig(); + AppPlusPayConfig.WechatConfig wechatConfig = new AppPlusPayConfig.WechatConfig(); + wechatConfig.setGzhAppId("wx-service-jsapi-appid"); + wechatConfig.setMerchantId("1900000001"); + wechatConfig.setBaseCallbackUrl("https://pay.example.com"); + wechatConfig.setPayCallbackUrl("/third-app-pay/app/pay-callback"); + appPlusPayConfig.setWechatConfig(wechatConfig); + service = new ThirdAggregateWechatPayServiceImpl(jsapiServiceExtension, appPlusPayConfig, thirdPayOrderService); + + context = new ThirdAggregatePayCreateContext(); + context.setPayNo("AGP202608050002"); + context.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY); + context.setBizId("order-1002"); + context.setUserId(-1L); + context.setMchId(88L); + context.setAmount(1234); + context.setExpireTime(LocalDateTime.now().plusMinutes(2)); + + MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); + wechatPaySetting.setSubMchid("1900000109"); + paySetting = new MchPaySettingVo(); + paySetting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); + } + + /** + * 验证服务商 JSAPI 下单使用统一 sp_appid、sp_openid,并且不传逐商户 sub_appid。 + */ + @Test + void createPayShouldSetPartnerJsapiParameters() { + PrepayWithRequestPaymentResponse response = completeResponse(); + when(jsapiServiceExtension.prepayWithRequestPayment(any(PrepayRequest.class), + eq("wx-service-jsapi-appid"))).thenReturn(response); + when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(true); + + ThirdAggregateCashierLaunch result = service.createPay(context, paySetting, "sp-open-id"); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PrepayRequest.class); + verify(jsapiServiceExtension).prepayWithRequestPayment(requestCaptor.capture(), + eq("wx-service-jsapi-appid")); + PrepayRequest request = requestCaptor.getValue(); + Assertions.assertEquals("wx-service-jsapi-appid", request.getSpAppid()); + Assertions.assertEquals("1900000001", request.getSpMchid()); + Assertions.assertNull(request.getSubAppid()); + Assertions.assertEquals("1900000109", request.getSubMchid()); + Assertions.assertEquals("sp-open-id", request.getPayer().getSpOpenid()); + Assertions.assertNull(request.getPayer().getSubOpenid()); + Assertions.assertEquals(1234, request.getAmount().getTotal()); + Assertions.assertEquals("https://pay.example.com/third-app-pay/app/pay-callback", request.getNotifyUrl()); + Assertions.assertEquals(response.getPaySign(), result.getPayParams().get("paySign")); + + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).save(orderCaptor.capture()); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_UNPAID.getType(), orderCaptor.getValue().getStatus()); + Assertions.assertFalse(orderCaptor.getValue().getPayRep().contains("sp-open-id")); + Assertions.assertTrue(orderCaptor.getValue().getPayRep().contains("\"spAppid\":\"wx-service-jsapi-appid\"")); + Assertions.assertTrue(orderCaptor.getValue().getPayRep().contains("\"tradeType\":\"JSAPI\"")); + } + + /** + * 验证平台预支付成功但本地保存失败时立即使用原子订单号和子商户号发起关单。 + */ + @Test + void createPayShouldCloseWhenOrderPersistenceFails() { + when(jsapiServiceExtension.prepayWithRequestPayment(any(PrepayRequest.class), + eq("wx-service-jsapi-appid"))).thenReturn(completeResponse()); + when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(false); + + Assertions.assertThrows(RuntimeException.class, + () -> service.createPay(context, paySetting, "sp-open-id")); + + ArgumentCaptor closeCaptor = ArgumentCaptor.forClass(CloseOrderRequest.class); + verify(jsapiServiceExtension).closeOrder(closeCaptor.capture()); + Assertions.assertEquals(context.getPayNo(), closeCaptor.getValue().getOutTradeNo()); + Assertions.assertEquals("1900000001", closeCaptor.getValue().getSpMchid()); + Assertions.assertEquals("1900000109", closeCaptor.getValue().getSubMchid()); + } + + /** + * 构建字段完整的微信 JSBridge 预支付响应。 + * + * @return 可通过服务完整性校验的响应 + */ + private PrepayWithRequestPaymentResponse completeResponse() { + PrepayWithRequestPaymentResponse response = new PrepayWithRequestPaymentResponse(); + response.setAppId("wx-service-jsapi-appid"); + response.setTimeStamp("1785912000"); + response.setNonceStr("nonce"); + response.setPackageVal("prepay_id=wx-prepay-id"); + response.setSignType("RSA"); + response.setPaySign("pay-sign"); + return response; + } +} 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 new file mode 100644 index 000000000..0cc2edd02 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java @@ -0,0 +1,314 @@ +package com.bsd.pjcloud.thirdparty.service.impl.appplus; + +import cn.hutool.core.date.DatePattern; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.json.JSONUtil; +import com.alipay.api.AlipayClient; +import com.alipay.api.domain.AlipayTradePrecreateModel; +import com.alipay.api.request.AlipayTradePrecreateRequest; +import com.alipay.api.response.AlipayTradePrecreateResponse; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService; +import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService; +import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ThirdAppPlusAliPayServiceImplTest { + + @Mock + private ThirdPayOrderService thirdPayOrderService; + + @Mock + private AlipayClient alipayClient; + + /** + * 聚合会话服务 Mock,用于校验支付宝 JSAPI 回调必须存在严格 pay_no 绑定。 + */ + @Mock + private ThirdAggregatePaySessionService aggregatePaySessionService; + + private AppPlusPayConfig appPlusPayConfig; + + private MchPaySettingVo paySetting; + + private ThirdAppPlusPayDto payDto; + + private TestableThirdAppPlusAliPayService service; + + @BeforeEach + void setUp() { + appPlusPayConfig = new AppPlusPayConfig(); + AppPlusPayConfig.AliPayConfig aliPayConfig = new AppPlusPayConfig.AliPayConfig(); + aliPayConfig.setAppId("2026000000000001"); + aliPayConfig.setAppPrivateKey("test-private-key"); + aliPayConfig.setAlipayPublicKey("test-public-key"); + aliPayConfig.setBaseCallbackUrl("https://pay.example.com"); + aliPayConfig.setPayCallbackUrl("/third-app-pay/app/pay-callback"); + appPlusPayConfig.setAliPayConfig(aliPayConfig); + + MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting(); + aliPaySetting.setAppAuthToken("merchant-auth-token"); + paySetting = new MchPaySettingVo(); + paySetting.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType()); + paySetting.setEnable(YesNoEnum.YES.getCode()); + paySetting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); + + payDto = new ThirdAppPlusPayDto(); + payDto.setOutTradeNo("202608051234560001"); + payDto.setBizId("order-1001"); + payDto.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY); + payDto.setUserId(-1L); + payDto.setAmount(1234); + payDto.setMchId(88L); + service = new TestableThirdAppPlusAliPayService(appPlusPayConfig, thirdPayOrderService, alipayClient); + ReflectionTestUtils.setField(service, "aggregatePaySessionService", aggregatePaySessionService); + } + + @Test + void precreateShouldSetRequiredParametersAndSaveUnpaidOrder() throws Exception { + AlipayTradePrecreateResponse response = successPrecreateResponse("https://qr.alipay.com/test-content"); + when(alipayClient.execute(any(AlipayTradePrecreateRequest.class))).thenReturn(response); + LocalDateTime expireTime = LocalDateTime.now().plusMinutes(2); + + ThirdOrderCodePayVo result = service.precreate(payDto, paySetting, expireTime); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradePrecreateRequest.class); + verify(alipayClient).execute(requestCaptor.capture()); + AlipayTradePrecreateRequest request = requestCaptor.getValue(); + AlipayTradePrecreateModel model = (AlipayTradePrecreateModel) request.getBizModel(); + Assertions.assertEquals("QR_CODE_OFFLINE", model.getProductCode()); + Assertions.assertEquals("12.34", model.getTotalAmount()); + Assertions.assertEquals("资安信消费支付", model.getSubject()); + Assertions.assertEquals(LocalDateTimeUtil.format(expireTime, DatePattern.NORM_DATETIME_PATTERN), model.getTimeExpire()); + Assertions.assertEquals("merchant-auth-token", request.getTextParams().get("app_auth_token")); + Assertions.assertEquals("https://pay.example.com/third-app-pay/app/pay-callback", request.getNotifyUrl()); + Assertions.assertEquals(response.getQrCode(), result.getQrCode()); + + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).save(orderCaptor.capture()); + ThirdPayOrderEntity savedOrder = orderCaptor.getValue(); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_UNPAID.getType(), savedOrder.getStatus()); + Assertions.assertEquals(1234, savedOrder.getPrice()); + Assertions.assertEquals(expireTime, savedOrder.getExpireTime()); + Assertions.assertNull(savedOrder.getPayTime()); + Assertions.assertTrue(savedOrder.getPayRes().contains("qrCode")); + } + + @Test + void precreateShouldSaveFailedOrderWhenAlipayRejectsRequest() throws Exception { + AlipayTradePrecreateResponse response = new AlipayTradePrecreateResponse(); + response.setCode("40004"); + response.setSubCode("ACQ.INVALID_PARAMETER"); + when(alipayClient.execute(any(AlipayTradePrecreateRequest.class))).thenReturn(response); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, + () -> service.precreate(payDto, paySetting, LocalDateTime.now().plusMinutes(2))); + + Assertions.assertTrue(exception.getMessage().contains("ACQ.INVALID_PARAMETER")); + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).save(orderCaptor.capture()); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_FAILED.getType(), orderCaptor.getValue().getStatus()); + Assertions.assertNull(orderCaptor.getValue().getPayTime()); + } + + @Test + void precreateShouldRejectSuccessfulResponseWithoutQrCode() throws Exception { + when(alipayClient.execute(any(AlipayTradePrecreateRequest.class))).thenReturn(successPrecreateResponse(null)); + + Assertions.assertThrows(RuntimeException.class, + () -> service.precreate(payDto, paySetting, LocalDateTime.now().plusMinutes(2))); + + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).save(orderCaptor.capture()); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_FAILED.getType(), orderCaptor.getValue().getStatus()); + } + + @Test + void payCallbackShouldRejectInvalidSignatureBeforeReadingOrder() { + service.signatureValid = false; + + Assertions.assertNull(service.payCallback(successCallbackParams())); + + verify(thirdPayOrderService, never()).getByPayNo(any()); + } + + @Test + void payCallbackShouldRejectWrongAppIdAndAmount() { + ThirdPayOrderEntity order = unpaidOrder(); + when(thirdPayOrderService.getByPayNo(order.getPayNo())).thenReturn(order); + Map params = successCallbackParams(); + params.put("app_id", "wrong-app-id"); + Assertions.assertNull(service.payCallback(params)); + + params.put("app_id", appPlusPayConfig.getAliPayConfig().getAppId()); + params.put("total_amount", "12.35"); + Assertions.assertNull(service.payCallback(params)); + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + @Test + void payCallbackShouldAtomicallyMarkSuccessAndRecordTradeNo() { + ThirdPayOrderEntity order = unpaidOrder(); + ThirdPayOrderEntity paidOrder = unpaidOrder(); + paidOrder.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); + paidOrder.setThirdPayNo("202600000001"); + when(thirdPayOrderService.getByPayNo(order.getPayNo())).thenReturn(order); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + when(thirdPayOrderService.getById(order.getId())).thenReturn(paidOrder); + + ThirdAppPlusAliPayService.AliPayCallbackResult result = service.payCallback(successCallbackParams()); + + Assertions.assertNotNull(result); + Assertions.assertTrue(result.isPaySuccess()); + Assertions.assertTrue(result.isStateChanged()); + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType(), updateCaptor.getValue().getStatus()); + Assertions.assertEquals("202600000001", updateCaptor.getValue().getThirdPayNo()); + Assertions.assertNotNull(updateCaptor.getValue().getPayTime()); + } + + @Test + void payCallbackShouldAcceptDuplicateSuccessWithoutUpdatingAgain() { + ThirdPayOrderEntity paidOrder = unpaidOrder(); + paidOrder.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); + when(thirdPayOrderService.getByPayNo(paidOrder.getPayNo())).thenReturn(paidOrder); + + ThirdAppPlusAliPayService.AliPayCallbackResult result = service.payCallback(successCallbackParams()); + + Assertions.assertNotNull(result); + Assertions.assertTrue(result.isPaySuccess()); + Assertions.assertFalse(result.isStateChanged()); + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + @Test + void payCallbackShouldKeepClosedStateWhenCancelWinsConcurrency() { + ThirdPayOrderEntity order = unpaidOrder(); + ThirdPayOrderEntity closedOrder = unpaidOrder(); + closedOrder.setStatus(ThirdPayOrderStatusEnum.PAY_CLOSED.getType()); + when(thirdPayOrderService.getByPayNo(order.getPayNo())).thenReturn(order); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(false); + when(thirdPayOrderService.getById(order.getId())).thenReturn(closedOrder); + + ThirdAppPlusAliPayService.AliPayCallbackResult result = service.payCallback(successCallbackParams()); + + Assertions.assertNotNull(result); + Assertions.assertFalse(result.isPaySuccess()); + Assertions.assertFalse(result.isStateChanged()); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_CLOSED.getType(), result.getPayOrder().getStatus()); + } + + /** + * 验证支付宝聚合回调必须同时具备 JSAPI 请求快照、会话绑定和支付宝平台选择。 + */ + @Test + void payCallbackShouldAcceptBoundAggregateJsapiOrder() { + ThirdPayOrderEntity order = unpaidOrder(); + order.setPayRep(JSONUtil.createObj().set("aggregatePay", true) + .set("productCode", "JSAPI_PAY").toString()); + ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity(); + session.setPayNo(order.getPayNo()); + session.setSelectedPlatform(AppPlusPlatformTypeEnum.ALI_PAY.getType()); + session.setBizType(order.getBizType()); + session.setBizId(order.getBizId()); + session.setMchId(order.getMchId()); + session.setPrice(order.getPrice()); + when(aggregatePaySessionService.getByPayNo(order.getPayNo())).thenReturn(session); + when(thirdPayOrderService.getByPayNo(order.getPayNo())).thenReturn(order); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + ThirdPayOrderEntity paidOrder = unpaidOrder(); + paidOrder.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); + when(thirdPayOrderService.getById(order.getId())).thenReturn(paidOrder); + + ThirdAppPlusAliPayService.AliPayCallbackResult result = service.payCallback(successCallbackParams()); + + Assertions.assertNotNull(result); + Assertions.assertTrue(result.isPaySuccess()); + Assertions.assertTrue(result.isStateChanged()); + } + + private AlipayTradePrecreateResponse successPrecreateResponse(String qrCode) { + AlipayTradePrecreateResponse response = new AlipayTradePrecreateResponse(); + response.setCode("10000"); + response.setMsg("Success"); + response.setOutTradeNo(payDto.getOutTradeNo()); + response.setQrCode(qrCode); + return response; + } + + private ThirdPayOrderEntity unpaidOrder() { + ThirdPayOrderEntity order = new ThirdPayOrderEntity(); + order.setId(99L); + order.setPayNo(payDto.getOutTradeNo()); + order.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType()); + order.setBizType(payDto.getBizType().getType()); + order.setBizId(payDto.getBizId()); + order.setMchId(payDto.getMchId()); + order.setPrice(payDto.getAmount()); + order.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType()); + return order; + } + + private Map successCallbackParams() { + Map params = new HashMap<>(); + params.put("app_id", appPlusPayConfig.getAliPayConfig().getAppId()); + params.put("out_trade_no", payDto.getOutTradeNo()); + params.put("trade_no", "202600000001"); + params.put("trade_status", "TRADE_SUCCESS"); + params.put("total_amount", "12.34"); + return params; + } + + private static final class TestableThirdAppPlusAliPayService extends ThirdAppPlusAliPayServiceImpl { + + private final AlipayClient testAlipayClient; + + private boolean signatureValid = true; + + private TestableThirdAppPlusAliPayService(AppPlusPayConfig appPlusPayConfig, ThirdPayOrderService thirdPayOrderService, + AlipayClient testAlipayClient) { + super(appPlusPayConfig, thirdPayOrderService); + this.testAlipayClient = testAlipayClient; + } + + @Override + protected AlipayClient getAlipayClient() { + return testAlipayClient; + } + + @Override + protected boolean verifyPayCallbackSignature(Map params) { + return signatureValid; + } + } +} 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 new file mode 100644 index 000000000..ffc792f41 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java @@ -0,0 +1,522 @@ +package com.bsd.pjcloud.thirdparty.service.impl.appplus; + +import cn.hutool.json.JSONUtil; +import com.alipay.api.response.AlipayTradeCancelResponse; +import com.alipay.api.response.AlipayTradeQueryResponse; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.bsd.pjcloud.admin.api.feign.RemoteTokenService; +import com.bsd.pjcloud.admin.api.feign.RemoteUserRelationService; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.app.api.feign.RemoteAppUserService; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.hard.api.feign.RemoteHardOrderService; +import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdOrderCodePayDto; +import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.service.*; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayStatusVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.feign.RemoteWalletRechargePackageService; +import com.bsd.pjcloud.wallet.api.feign.RemoteWalletRechargeService; +import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ThirdAppPlusServiceImplOrderCodePayTest { + + @Mock + private AppPlusPayConfig appPlusPayConfig; + + @Mock + private ThirdAppPlusWechatService thirdMiniAppWechatService; + + @Mock + private ThirdAppPlusWechatNativePayService thirdAppPlusWechatNativePayService; + + @Mock + private ThirdAppPlusWechatMicropayService thirdAppPlusWechatMicropayService; + + @Mock + private ThirdAppPlusAliPayService thirdMiniAppAliPayService; + + @Mock + private ThirdPayLogService thirdPayLogService; + + @Mock + private ThirdPayOrderService thirdPayOrderService; + + /** + * 聚合支付服务 Mock,用于验证旧原生订单码创建前执行跨模式互斥检查。 + */ + @Mock + private ThirdAggregateOrderPayService thirdAggregateOrderPayService; + + @Mock + private StringRedisTemplate stringRedisTemplate; + + @Mock + private ValueOperations valueOperations; + + @Mock + private RemoteUserService remoteUserService; + + @Mock + private RemoteUserRelationService remoteUserRelationService; + + @Mock + private RemoteTokenService remoteTokenService; + + @Mock + private RemoteAppUserService remoteAppUserService; + + @Mock + private RemoteWalletRechargePackageService remoteWalletRechargePackageService; + + @Mock + private RemoteWalletRechargeService remoteWalletRechargeService; + + @Mock + private RemoteWalletService remoteWalletService; + + @Mock + private RemoteMchService remoteMchService; + + @Mock + private RemoteHardOrderService remoteHardOrderService; + + @InjectMocks + private ThirdAppPlusServiceImpl service; + + private ThirdOrderCodePayDto request; + + private MchManageVo merchant; + + @BeforeEach + void setUp() { + lenient().when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); + lenient().when(valueOperations.setIfAbsent(any(), any(), any())).thenReturn(true); + + AppPlusPayConfig.AliPayConfig aliPayConfig = new AppPlusPayConfig.AliPayConfig(); + aliPayConfig.setAppId("2026000000000001"); + aliPayConfig.setAppPrivateKey("test-private-key"); + aliPayConfig.setAlipayPublicKey("test-public-key"); + aliPayConfig.setBaseCallbackUrl("https://pay.example.com"); + aliPayConfig.setPayCallbackUrl("/third-app-pay/app/pay-callback"); + lenient().when(appPlusPayConfig.getAliPayConfig()).thenReturn(aliPayConfig); + + AppPlusPayConfig.WechatConfig wechatConfig = new AppPlusPayConfig.WechatConfig(); + wechatConfig.setAppPlatformAppId("wx-service-app-id"); + wechatConfig.setMerchantId("1900000001"); + wechatConfig.setMchPrivatePath("/tmp/test-private-key.pem"); + wechatConfig.setMerchantSerialNumber("test-serial-number"); + wechatConfig.setApiV3Key("test-api-v3-key"); + wechatConfig.setBaseCallbackUrl("https://pay.example.com"); + wechatConfig.setPayCallbackUrl("/third-app-pay/app/pay-callback"); + lenient().when(appPlusPayConfig.getWechatConfig()).thenReturn(wechatConfig); + + request = new ThirdOrderCodePayDto(); + request.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY); + request.setBizId("order-1001"); + request.setMiniPlatformType(AppPlusPlatformTypeEnum.ALI_PAY); + request.setAmount(1234); + request.setMchId(88L); + + merchant = enabledAliPayMerchant(); + } + + @Test + void orderCodePayShouldRejectUnsupportedBizPlatformAndAmount() { + request.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY); + Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request)); + + request.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY); + request.setMiniPlatformType(AppPlusPlatformTypeEnum.ZAX_APP_WALLET); + Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request)); + + request.setMiniPlatformType(AppPlusPlatformTypeEnum.ALI_PAY); + request.setAmount(0); + Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request)); + verify(thirdMiniAppAliPayService, never()).precreate(any(), any(), any()); + } + + @Test + void orderCodePayShouldRejectMissingMerchantAndAliPayConfiguration() { + mockNoExistingOrder(); + when(remoteMchService.getById(88L)).thenReturn(R.ok()); + Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request)); + + when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); + 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 + void orderCodePayShouldCreateNewOrderAndUseAnonymousUser() { + mockNoExistingOrder(); + when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); + ThirdOrderCodePayVo expected = orderCodePayVo("new-pay-no", "new-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); + ArgumentCaptor expireCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(thirdMiniAppAliPayService).precreate(payDtoCaptor.capture(), any(), expireCaptor.capture()); + Assertions.assertEquals(-1L, payDtoCaptor.getValue().getUserId()); + Assertions.assertEquals(1234, payDtoCaptor.getValue().getAmount()); + Assertions.assertEquals(88L, payDtoCaptor.getValue().getMchId()); + Assertions.assertTrue(expireCaptor.getValue().isAfter(LocalDateTime.now().plusSeconds(110))); + Assertions.assertTrue(expireCaptor.getValue().isBefore(LocalDateTime.now().plusSeconds(130))); + ArgumentCaptor lockKeyCaptor = ArgumentCaptor.forClass(String.class); + verify(valueOperations).setIfAbsent(lockKeyCaptor.capture(), any(), any()); + Assertions.assertEquals("thirdparty:order-code-pay:lock:20:order-1001", lockKeyCaptor.getValue()); + } + + @Test + void orderCodePayShouldCreateWechatNativeOrder() { + request.setMiniPlatformType(AppPlusPlatformTypeEnum.WECHAT); + merchant = enabledWechatMerchant(); + mockNoExistingOrder(); + when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); + ThirdOrderCodePayVo expected = orderCodePayVo("wechat-pay-no", "wechat-code-url", 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.assertEquals(-1L, payDtoCaptor.getValue().getUserId()); + Assertions.assertEquals(1234, payDtoCaptor.getValue().getAmount()); + verify(thirdMiniAppAliPayService, never()).precreate(any(), any(), any()); + } + + @Test + void orderCodePayShouldReuseValidUnpaidQrCode() { + when(thirdPayOrderService.count(any(Wrapper.class))).thenReturn(0L); + ThirdPayOrderEntity unpaidOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().plusMinutes(1)); + unpaidOrder.setPayRes(JSONUtil.createObj().set("qrCode", "existing-qr").toString()); + when(thirdPayOrderService.getOne(any(Wrapper.class))).thenReturn(unpaidOrder); + + ThirdOrderCodePayVo result = service.orderCodePay(request); + + Assertions.assertEquals(unpaidOrder.getPayNo(), result.getPayNo()); + Assertions.assertEquals("existing-qr", result.getQrCode()); + Assertions.assertEquals(unpaidOrder.getExpireTime(), result.getExpireTime()); + verify(thirdMiniAppAliPayService, never()).precreate(any(), any(), any()); + } + + @Test + void orderCodePayShouldRejectAlreadyPaidBusinessOrder() { + when(thirdPayOrderService.count(any(Wrapper.class))).thenReturn(1L); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request)); + + Assertions.assertTrue(exception.getMessage().contains("已支付")); + verify(thirdMiniAppAliPayService, never()).precreate(any(), any(), any()); + } + + @Test + void orderCodePayShouldRejectAnotherPlatformsValidQrCode() { + request.setMiniPlatformType(AppPlusPlatformTypeEnum.WECHAT); + when(thirdPayOrderService.count(any(Wrapper.class))).thenReturn(0L); + ThirdPayOrderEntity unpaidOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().plusMinutes(1)); + unpaidOrder.setPayRes(JSONUtil.createObj().set("qrCode", "alipay-existing-qr").toString()); + when(thirdPayOrderService.getOne(any(Wrapper.class))).thenReturn(unpaidOrder); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request)); + + Assertions.assertTrue(exception.getMessage().contains("其他平台")); + verify(thirdAppPlusWechatNativePayService, never()).precreate(any(), any(), any()); + } + + @Test + void getOrderCodePayStatusShouldReturnOnlyInternalState() { + ThirdPayOrderEntity paidOrder = order(ThirdPayOrderStatusEnum.PAY_SUCCESS, LocalDateTime.now().plusMinutes(1)); + paidOrder.setMchPaySettingJson("sensitive-setting-snapshot"); + paidOrder.setPayRes("sensitive-alipay-response"); + when(thirdPayOrderService.getByPayNo(paidOrder.getPayNo())).thenReturn(paidOrder); + + ThirdOrderCodePayStatusVo result = service.getOrderCodePayStatus(paidOrder.getPayNo()); + + Assertions.assertEquals(paidOrder.getPayNo(), result.getPayNo()); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType(), result.getStatus()); + Assertions.assertTrue(result.getPaySuccess()); + Assertions.assertEquals(paidOrder.getExpireTime(), result.getExpireTime()); + } + + @Test + void orderCodePayShouldCloseExpiredOrderBeforeCreatingReplacement() { + when(thirdPayOrderService.count(any(Wrapper.class))).thenReturn(0L); + ThirdPayOrderEntity expiredOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().minusSeconds(1)); + when(thirdPayOrderService.getOne(any(Wrapper.class))).thenReturn(expiredOrder); + AlipayTradeQueryResponse queryResponse = queryResponse("WAIT_BUYER_PAY", null); + when(thirdMiniAppAliPayService.query(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())).thenReturn(queryResponse); + AlipayTradeCancelResponse cancelResponse = new AlipayTradeCancelResponse(); + cancelResponse.setCode("10000"); + cancelResponse.setRetryFlag("N"); + cancelResponse.setAction("close"); + when(thirdMiniAppAliPayService.cancel(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())).thenReturn(cancelResponse); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + ThirdPayOrderEntity closedOrder = order(ThirdPayOrderStatusEnum.PAY_CLOSED, expiredOrder.getExpireTime()); + when(thirdPayOrderService.getById(expiredOrder.getId())).thenReturn(closedOrder); + when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); + ThirdOrderCodePayVo expected = orderCodePayVo("replacement-pay-no", "replacement-qr", LocalDateTime.now().plusMinutes(2)); + when(thirdMiniAppAliPayService.precreate(any(), any(), any())).thenReturn(expected); + + ThirdOrderCodePayVo result = service.orderCodePay(request); + + Assertions.assertSame(expected, result); + verify(thirdMiniAppAliPayService).query(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson()); + verify(thirdMiniAppAliPayService).cancel(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson()); + verify(thirdMiniAppAliPayService).precreate(any(), any(), any()); + } + + @Test + void queryPayResultShouldKeepWaitingOrderBeforeExpiry() { + ThirdPayOrderEntity unpaidOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().plusMinutes(1)); + when(thirdMiniAppAliPayService.query(unpaidOrder.getPayNo(), unpaidOrder.getMchPaySettingJson())) + .thenReturn(queryResponse("WAIT_BUYER_PAY", null)); + + service.queryPayResult(unpaidOrder); + + verify(thirdMiniAppAliPayService, never()).cancel(any(), any()); + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + @Test + void queryPayResultShouldMarkTradeFinishedAsSuccess() { + ThirdPayOrderEntity unpaidOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().plusMinutes(1)); + when(thirdMiniAppAliPayService.query(unpaidOrder.getPayNo(), unpaidOrder.getMchPaySettingJson())) + .thenReturn(queryResponse("TRADE_FINISHED", "alipay-trade-no")); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + ThirdPayOrderEntity paidOrder = order(ThirdPayOrderStatusEnum.PAY_SUCCESS, unpaidOrder.getExpireTime()); + when(thirdPayOrderService.getById(unpaidOrder.getId())).thenReturn(paidOrder); + + service.queryPayResult(unpaidOrder); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService, org.mockito.Mockito.atLeastOnce()).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType(), updateCaptor.getAllValues().get(0).getStatus()); + Assertions.assertEquals("alipay-trade-no", updateCaptor.getAllValues().get(0).getThirdPayNo()); + } + + @Test + void queryPayResultShouldMarkTradeSuccessAsPaid() { + ThirdPayOrderEntity unpaidOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().plusMinutes(1)); + when(thirdMiniAppAliPayService.query(unpaidOrder.getPayNo(), unpaidOrder.getMchPaySettingJson())) + .thenReturn(queryResponse("TRADE_SUCCESS", "alipay-success-no")); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + ThirdPayOrderEntity paidOrder = order(ThirdPayOrderStatusEnum.PAY_SUCCESS, unpaidOrder.getExpireTime()); + paidOrder.setIsBizCallback(YesNoEnum.YES.getCode()); + when(thirdPayOrderService.getById(unpaidOrder.getId())).thenReturn(paidOrder); + + service.queryPayResult(unpaidOrder); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType(), updateCaptor.getValue().getStatus()); + Assertions.assertEquals("alipay-success-no", updateCaptor.getValue().getThirdPayNo()); + } + + @Test + void queryPayResultShouldMarkTradeClosedAsClosed() { + ThirdPayOrderEntity unpaidOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().plusMinutes(1)); + when(thirdMiniAppAliPayService.query(unpaidOrder.getPayNo(), unpaidOrder.getMchPaySettingJson())) + .thenReturn(queryResponse("TRADE_CLOSED", null)); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.queryPayResult(unpaidOrder); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_CLOSED.getType(), updateCaptor.getValue().getStatus()); + } + + @Test + void queryPayResultShouldCloseExpiredOrderAfterCancelSucceeds() { + ThirdPayOrderEntity expiredOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().minusSeconds(1)); + when(thirdMiniAppAliPayService.query(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())) + .thenReturn(queryResponse("WAIT_BUYER_PAY", null)); + AlipayTradeCancelResponse cancelResponse = new AlipayTradeCancelResponse(); + cancelResponse.setCode("10000"); + cancelResponse.setRetryFlag("N"); + cancelResponse.setAction("close"); + when(thirdMiniAppAliPayService.cancel(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())).thenReturn(cancelResponse); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.queryPayResult(expiredOrder); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_CLOSED.getType(), updateCaptor.getValue().getStatus()); + } + + @Test + void queryPayResultShouldRetryCancelAndKeepUnpaidState() { + ThirdPayOrderEntity expiredOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().minusSeconds(1)); + when(thirdMiniAppAliPayService.query(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())) + .thenReturn(queryResponse("WAIT_BUYER_PAY", null)); + AlipayTradeCancelResponse cancelResponse = new AlipayTradeCancelResponse(); + cancelResponse.setCode("10000"); + cancelResponse.setRetryFlag("Y"); + when(thirdMiniAppAliPayService.cancel(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())).thenReturn(cancelResponse); + + service.queryPayResult(expiredOrder); + + verify(thirdMiniAppAliPayService).cancel(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson()); + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + @Test + void queryPayResultShouldKeepUnpaidStateOnNetworkFailure() { + ThirdPayOrderEntity expiredOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().minusSeconds(1)); + when(thirdMiniAppAliPayService.query(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())).thenReturn(null); + + service.queryPayResult(expiredOrder); + + verify(thirdMiniAppAliPayService, never()).cancel(any(), any()); + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + @Test + void queryPayResultShouldMarkWechatNativeSuccess() { + ThirdPayOrderEntity unpaidOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().plusMinutes(1)); + unpaidOrder.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType()); + com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction transaction = + new com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction(); + transaction.setTradeState(com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction.TradeStateEnum.SUCCESS); + transaction.setTransactionId("wechat-transaction-id"); + when(thirdAppPlusWechatNativePayService.query(unpaidOrder.getPayNo(), unpaidOrder.getMchPaySettingJson())) + .thenReturn(transaction); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + ThirdPayOrderEntity paidOrder = order(ThirdPayOrderStatusEnum.PAY_SUCCESS, unpaidOrder.getExpireTime()); + paidOrder.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType()); + paidOrder.setIsBizCallback(YesNoEnum.YES.getCode()); + when(thirdPayOrderService.getById(unpaidOrder.getId())).thenReturn(paidOrder); + + service.queryPayResult(unpaidOrder); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType(), updateCaptor.getValue().getStatus()); + Assertions.assertEquals("wechat-transaction-id", updateCaptor.getValue().getThirdPayNo()); + } + + @Test + void queryPayResultShouldCloseExpiredWechatNativeOrder() { + ThirdPayOrderEntity expiredOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().minusSeconds(1)); + expiredOrder.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType()); + com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction transaction = + new com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction(); + transaction.setTradeState(com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction.TradeStateEnum.NOTPAY); + when(thirdAppPlusWechatNativePayService.query(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())) + .thenReturn(transaction); + when(thirdAppPlusWechatNativePayService.close(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())) + .thenReturn(true); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.queryPayResult(expiredOrder); + + verify(thirdAppPlusWechatNativePayService).close(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson()); + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_CLOSED.getType(), updateCaptor.getValue().getStatus()); + } + + private void mockNoExistingOrder() { + when(thirdPayOrderService.count(any(Wrapper.class))).thenReturn(0L); + when(thirdPayOrderService.getOne(any(Wrapper.class))).thenReturn(null); + } + + private MchManageVo enabledAliPayMerchant() { + MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting(); + aliPaySetting.setAppAuthToken("merchant-auth-token"); + MchPaySettingVo setting = new MchPaySettingVo(); + setting.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType()); + setting.setEnable(YesNoEnum.YES.getCode()); + setting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); + MchManageVo result = new MchManageVo(); + result.setId(88L); + result.setPaySettingList(List.of(setting)); + return result; + } + + private MchManageVo enabledWechatMerchant() { + MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); + wechatPaySetting.setSubMchid("1900000109"); + MchPaySettingVo setting = new MchPaySettingVo(); + setting.setPlatformType(AppPlusPlatformPayType.WECHAT.getType()); + setting.setEnable(YesNoEnum.YES.getCode()); + setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); + MchManageVo result = new MchManageVo(); + result.setId(88L); + result.setPaySettingList(List.of(setting)); + return result; + } + + private ThirdPayOrderEntity order(ThirdPayOrderStatusEnum status, LocalDateTime expireTime) { + ThirdPayOrderEntity order = new ThirdPayOrderEntity(); + order.setId(99L); + order.setPayNo("existing-pay-no"); + order.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY.getType()); + order.setBizId(request.getBizId()); + order.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType()); + order.setStatus(status.getType()); + order.setPrice(request.getAmount()); + order.setMchId(request.getMchId()); + order.setMchPaySettingJson("merchant-setting-snapshot"); + order.setExpireTime(expireTime); + return order; + } + + private AlipayTradeQueryResponse queryResponse(String tradeStatus, String tradeNo) { + AlipayTradeQueryResponse response = new AlipayTradeQueryResponse(); + response.setCode("10000"); + response.setTradeStatus(tradeStatus); + response.setTradeNo(tradeNo); + return response; + } + + private ThirdOrderCodePayVo orderCodePayVo(String payNo, String qrCode, LocalDateTime expireTime) { + ThirdOrderCodePayVo result = new ThirdOrderCodePayVo(); + result.setPayNo(payNo); + result.setQrCode(qrCode); + result.setExpireTime(expireTime); + return result; + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatNativePayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatNativePayServiceImplTest.java new file mode 100644 index 000000000..f1fd25718 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatNativePayServiceImplTest.java @@ -0,0 +1,168 @@ +package com.bsd.pjcloud.thirdparty.service.impl.appplus; + +import cn.hutool.json.JSONUtil; +import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import com.wechat.pay.java.core.exception.ServiceException; +import com.wechat.pay.java.service.partnerpayments.nativepay.NativePayService; +import com.wechat.pay.java.service.partnerpayments.nativepay.model.CloseOrderRequest; +import com.wechat.pay.java.service.partnerpayments.nativepay.model.PrepayRequest; +import com.wechat.pay.java.service.partnerpayments.nativepay.model.PrepayResponse; +import com.wechat.pay.java.service.partnerpayments.nativepay.model.QueryOrderByOutTradeNoRequest; +import com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.time.OffsetDateTime; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 微信服务商 Native 订单码服务参数和异常持久化测试。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdAppPlusWechatNativePayServiceImplTest { + + @Mock + private NativePayService nativePayService; + + @Mock + private AppPlusPayConfig appPlusPayConfig; + + @Mock + private ThirdPayOrderService thirdPayOrderService; + + @InjectMocks + private ThirdAppPlusWechatNativePayServiceImpl service; + + private ThirdAppPlusPayDto payDto; + + private MchPaySettingVo paySetting; + + @BeforeEach + void setUp() { + AppPlusPayConfig.WechatConfig wechatConfig = new AppPlusPayConfig.WechatConfig(); + wechatConfig.setAppPlatformAppId("wx-service-app-id"); + wechatConfig.setMerchantId("1900000001"); + wechatConfig.setBaseCallbackUrl("https://pay.example.com"); + wechatConfig.setPayCallbackUrl("/third-app-pay/app/pay-callback"); + when(appPlusPayConfig.getWechatConfig()).thenReturn(wechatConfig); + + payDto = new ThirdAppPlusPayDto(); + payDto.setOutTradeNo("202608050001"); + payDto.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY); + payDto.setBizId("order-1001"); + payDto.setUserId(-1L); + payDto.setMchId(88L); + payDto.setAmount(1234); + + MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); + wechatPaySetting.setSubMchid("1900000109"); + paySetting = new MchPaySettingVo(); + paySetting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); + } + + @Test + void precreateShouldBuildPartnerNativeRequestAndSaveUnpaidOrder() { + PrepayResponse response = new PrepayResponse(); + response.setCodeUrl("weixin://wxpay/bizpayurl/up?pr=test"); + when(nativePayService.prepay(any())).thenReturn(response); + LocalDateTime expireTime = LocalDateTime.now().plusMinutes(2); + + ThirdOrderCodePayVo result = service.precreate(payDto, paySetting, expireTime); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PrepayRequest.class); + verify(nativePayService).prepay(requestCaptor.capture()); + PrepayRequest request = requestCaptor.getValue(); + Assertions.assertEquals("wx-service-app-id", request.getSpAppid()); + Assertions.assertEquals("1900000001", request.getSpMchid()); + Assertions.assertEquals("1900000109", request.getSubMchid()); + Assertions.assertEquals(1234, request.getAmount().getTotal()); + Assertions.assertEquals("CNY", request.getAmount().getCurrency()); + Assertions.assertEquals("资安信消费支付", request.getDescription()); + Assertions.assertEquals("https://pay.example.com/third-app-pay/app/pay-callback", request.getNotifyUrl()); + Assertions.assertDoesNotThrow(() -> OffsetDateTime.parse(request.getTimeExpire())); + + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).save(orderCaptor.capture()); + ThirdPayOrderEntity savedOrder = orderCaptor.getValue(); + Assertions.assertEquals(AppPlusPlatformTypeEnum.WECHAT.getType(), savedOrder.getPayPlatformType()); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_UNPAID.getType(), savedOrder.getStatus()); + Assertions.assertEquals(expireTime, savedOrder.getExpireTime()); + Assertions.assertEquals(response.getCodeUrl(), JSONUtil.parseObj(savedOrder.getPayRes()).getStr("qrCode")); + Assertions.assertNull(savedOrder.getPayTime()); + Assertions.assertEquals(response.getCodeUrl(), result.getQrCode()); + } + + @Test + void precreateShouldSaveFailureWhenWechatReturnsEmptyCodeUrl() { + when(nativePayService.prepay(any())).thenReturn(new PrepayResponse()); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, + () -> service.precreate(payDto, paySetting, LocalDateTime.now().plusMinutes(2))); + + Assertions.assertTrue(exception.getMessage().contains("未返回二维码")); + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).save(orderCaptor.capture()); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_FAILED.getType(), orderCaptor.getValue().getStatus()); + Assertions.assertEquals("EMPTY_CODE_URL", JSONUtil.parseObj(orderCaptor.getValue().getPayRes()).getStr("code")); + Assertions.assertNull(orderCaptor.getValue().getPayTime()); + } + + @Test + void precreateShouldSaveWechatErrorCode() { + ServiceException serviceException = org.mockito.Mockito.mock(ServiceException.class); + when(serviceException.getErrorCode()).thenReturn("APPID_MCHID_NOT_MATCH"); + when(nativePayService.prepay(any())).thenThrow(serviceException); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, + () -> service.precreate(payDto, paySetting, LocalDateTime.now().plusMinutes(2))); + + Assertions.assertTrue(exception.getMessage().contains("APPID_MCHID_NOT_MATCH")); + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).save(orderCaptor.capture()); + Assertions.assertEquals("APPID_MCHID_NOT_MATCH", JSONUtil.parseObj(orderCaptor.getValue().getPayRes()).getStr("code")); + } + + @Test + void queryAndCloseShouldUseServiceProviderAndSubMerchantNumbers() { + MchPaySettingVo settingSnapshot = new MchPaySettingVo(); + settingSnapshot.setSettingJson(paySetting.getSettingJson()); + String settingJson = JSONUtil.toJsonStr(settingSnapshot); + Transaction transaction = new Transaction(); + transaction.setTradeState(Transaction.TradeStateEnum.NOTPAY); + when(nativePayService.queryOrderByOutTradeNo(any())).thenReturn(transaction); + + Assertions.assertSame(transaction, service.query(payDto.getOutTradeNo(), settingJson)); + Assertions.assertTrue(service.close(payDto.getOutTradeNo(), settingJson)); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(QueryOrderByOutTradeNoRequest.class); + verify(nativePayService).queryOrderByOutTradeNo(queryCaptor.capture()); + Assertions.assertEquals("1900000001", queryCaptor.getValue().getSpMchid()); + Assertions.assertEquals("1900000109", queryCaptor.getValue().getSubMchid()); + Assertions.assertEquals(payDto.getOutTradeNo(), queryCaptor.getValue().getOutTradeNo()); + + ArgumentCaptor closeCaptor = ArgumentCaptor.forClass(CloseOrderRequest.class); + verify(nativePayService).closeOrder(closeCaptor.capture()); + Assertions.assertEquals("1900000001", closeCaptor.getValue().getSpMchid()); + Assertions.assertEquals("1900000109", closeCaptor.getValue().getSubMchid()); + Assertions.assertEquals(payDto.getOutTradeNo(), closeCaptor.getValue().getOutTradeNo()); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java new file mode 100644 index 000000000..4692cfbcb --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java @@ -0,0 +1,244 @@ +package com.bsd.pjcloud.thirdparty.service.impl.appplus; + +import cn.binarywang.wx.miniapp.api.WxMaService; +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService; +import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import com.bsd.pjcloud.thirdparty.service.impl.appplus.fz.lifecycle.ThirdAppPlusWechatFzLifecycle; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import com.wechat.pay.java.core.notification.NotificationParser; +import com.wechat.pay.java.core.notification.RequestParam; +import com.wechat.pay.java.service.partnerpayments.app.AppServiceExtension; +import com.wechat.pay.java.service.partnerpayments.app.model.Transaction; +import com.wechat.pay.java.service.partnerpayments.app.model.TransactionAmount; +import com.wechat.pay.java.service.refund.RefundService; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 微信支付回调关键字段校验与幂等状态更新测试。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdAppPlusWechatServiceImplPayCallbackTest { + + @Mock + private AppPlusPayConfig appPlusPayConfig; + + @Mock + private WxMaService wxMaService; + + @Mock + private ThirdPayOrderService thirdPayOrderService; + + @Mock + private AppServiceExtension appServiceExtension; + + @Mock + private RefundService refundService; + + @Mock + private NotificationParser notificationParser; + + @Mock + private ThirdAppPlusWechatFzLifecycle appPlusWechatFzLifecycle; + + /** + * 聚合支付会话服务 Mock,用于严格区分旧 Native 和新 JSAPI 子订单回调。 + */ + @Mock + private ThirdAggregatePaySessionService aggregatePaySessionService; + + @InjectMocks + private ThirdAppPlusWechatServiceImpl service; + + private ThirdPayOrderEntity unpaidOrder; + + private Transaction callbackTransaction; + + private Map callbackHeaders; + + @BeforeEach + void setUp() { + AppPlusPayConfig.WechatConfig wechatConfig = new AppPlusPayConfig.WechatConfig(); + wechatConfig.setAppPlatformAppId("wx-service-app-id"); + wechatConfig.setGzhAppId("wx-service-jsapi-appid"); + wechatConfig.setMerchantId("1900000001"); + org.mockito.Mockito.lenient().when(appPlusPayConfig.getWechatConfig()).thenReturn(wechatConfig); + + MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); + wechatPaySetting.setSubMchid("1900000109"); + MchPaySettingVo settingSnapshot = new MchPaySettingVo(); + settingSnapshot.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); + + unpaidOrder = new ThirdPayOrderEntity(); + unpaidOrder.setId(99L); + unpaidOrder.setPayNo("202608050001"); + unpaidOrder.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType()); + unpaidOrder.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY.getType()); + unpaidOrder.setBizId("order-1001"); + unpaidOrder.setMchId(88L); + unpaidOrder.setPrice(1234); + unpaidOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType()); + unpaidOrder.setExpireTime(LocalDateTime.now().plusMinutes(1)); + unpaidOrder.setMchPaySettingJson(JSONUtil.toJsonStr(settingSnapshot)); + + TransactionAmount amount = new TransactionAmount(); + amount.setTotal(1234); + callbackTransaction = new Transaction(); + callbackTransaction.setSpAppid("wx-service-app-id"); + callbackTransaction.setSpMchid("1900000001"); + callbackTransaction.setSubMchid("1900000109"); + callbackTransaction.setOutTradeNo(unpaidOrder.getPayNo()); + callbackTransaction.setTransactionId("wechat-transaction-id"); + callbackTransaction.setTradeType(Transaction.TradeTypeEnum.NATIVE); + callbackTransaction.setTradeState(Transaction.TradeStateEnum.SUCCESS); + callbackTransaction.setAmount(amount); + + callbackHeaders = Map.of( + "wechatpay-serial", "serial", + "wechatpay-nonce", "nonce", + "wechatpay-signature", "signature", + "wechatpay-timestamp", "1785912000"); + } + + @Test + void payCallbackShouldValidateAndAtomicallyMarkOrderPaid() { + when(notificationParser.parse(any(RequestParam.class), eq(Transaction.class))).thenReturn(callbackTransaction); + when(thirdPayOrderService.getByPayNo(unpaidOrder.getPayNo())).thenReturn(unpaidOrder); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + ThirdPayOrderEntity paidOrder = copyAsPaid(unpaidOrder); + when(thirdPayOrderService.getById(unpaidOrder.getId())).thenReturn(paidOrder); + + ThirdPayOrderEntity result = service.payCallback("encrypted-body", callbackHeaders); + + Assertions.assertSame(paidOrder, result); + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType(), updateCaptor.getValue().getStatus()); + Assertions.assertEquals("wechat-transaction-id", updateCaptor.getValue().getThirdPayNo()); + Assertions.assertEquals(YesNoEnum.NO.getCode(), updateCaptor.getValue().getIsBizCallback()); + } + + @Test + void payCallbackShouldRejectAmountMismatch() { + callbackTransaction.getAmount().setTotal(1235); + when(notificationParser.parse(any(RequestParam.class), eq(Transaction.class))).thenReturn(callbackTransaction); + when(thirdPayOrderService.getByPayNo(unpaidOrder.getPayNo())).thenReturn(unpaidOrder); + + Assertions.assertNull(service.payCallback("encrypted-body", callbackHeaders)); + + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + @Test + void payCallbackShouldAcknowledgeRepeatedSuccessWithoutAnotherStateUpdate() { + ThirdPayOrderEntity paidOrder = copyAsPaid(unpaidOrder); + when(notificationParser.parse(any(RequestParam.class), eq(Transaction.class))).thenReturn(callbackTransaction); + when(thirdPayOrderService.getByPayNo(unpaidOrder.getPayNo())).thenReturn(paidOrder); + + Assertions.assertSame(paidOrder, service.payCallback("encrypted-body", callbackHeaders)); + + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + /** + * 验证聚合回调只接受统一服务商公众号 JSAPI 应用和绑定会话组合。 + */ + @Test + void payCallbackShouldAcceptBoundAggregateJsapiOrder() { + unpaidOrder.setPayRep(JSONUtil.createObj() + .set("aggregatePay", true) + .set("tradeType", "JSAPI") + .set("spAppid", "wx-service-jsapi-appid").toString()); + callbackTransaction.setSpAppid("wx-service-jsapi-appid"); + callbackTransaction.setSubAppid(null); + callbackTransaction.setTradeType(Transaction.TradeTypeEnum.JSAPI); + ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity(); + session.setPayNo(unpaidOrder.getPayNo()); + session.setSelectedPlatform(AppPlusPlatformTypeEnum.WECHAT.getType()); + session.setBizType(unpaidOrder.getBizType()); + session.setBizId(unpaidOrder.getBizId()); + session.setMchId(unpaidOrder.getMchId()); + session.setPrice(unpaidOrder.getPrice()); + when(aggregatePaySessionService.getByPayNo(unpaidOrder.getPayNo())).thenReturn(session); + when(notificationParser.parse(any(RequestParam.class), eq(Transaction.class))).thenReturn(callbackTransaction); + when(thirdPayOrderService.getByPayNo(unpaidOrder.getPayNo())).thenReturn(unpaidOrder); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + ThirdPayOrderEntity paidOrder = copyAsPaid(unpaidOrder); + when(thirdPayOrderService.getById(unpaidOrder.getId())).thenReturn(paidOrder); + + ThirdPayOrderEntity result = service.payCallback("encrypted-body", callbackHeaders); + + Assertions.assertSame(paidOrder, result); + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertNull(updateCaptor.getValue().getIsBizCallback()); + } + + /** + * 验证聚合订单误带子商户公众号 AppID 时拒绝回调,确保统一使用服务商平台公众号身份。 + */ + @Test + void payCallbackShouldRejectAggregateOrderWithSubAppid() { + unpaidOrder.setPayRep(JSONUtil.createObj() + .set("aggregatePay", true) + .set("tradeType", "JSAPI") + .set("spAppid", "wx-service-jsapi-appid").toString()); + callbackTransaction.setSpAppid("wx-service-jsapi-appid"); + callbackTransaction.setSubAppid("wx-sub-merchant-appid"); + callbackTransaction.setTradeType(Transaction.TradeTypeEnum.JSAPI); + ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity(); + session.setPayNo(unpaidOrder.getPayNo()); + session.setSelectedPlatform(AppPlusPlatformTypeEnum.WECHAT.getType()); + session.setBizType(unpaidOrder.getBizType()); + session.setBizId(unpaidOrder.getBizId()); + session.setMchId(unpaidOrder.getMchId()); + session.setPrice(unpaidOrder.getPrice()); + when(aggregatePaySessionService.getByPayNo(unpaidOrder.getPayNo())).thenReturn(session); + when(notificationParser.parse(any(RequestParam.class), eq(Transaction.class))).thenReturn(callbackTransaction); + when(thirdPayOrderService.getByPayNo(unpaidOrder.getPayNo())).thenReturn(unpaidOrder); + + Assertions.assertNull(service.payCallback("encrypted-body", callbackHeaders)); + + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + private ThirdPayOrderEntity copyAsPaid(ThirdPayOrderEntity source) { + ThirdPayOrderEntity paidOrder = new ThirdPayOrderEntity(); + paidOrder.setId(source.getId()); + paidOrder.setPayNo(source.getPayNo()); + paidOrder.setPayPlatformType(source.getPayPlatformType()); + paidOrder.setBizType(source.getBizType()); + paidOrder.setBizId(source.getBizId()); + paidOrder.setMchId(source.getMchId()); + paidOrder.setPrice(source.getPrice()); + paidOrder.setExpireTime(source.getExpireTime()); + paidOrder.setMchPaySettingJson(source.getMchPaySettingJson()); + paidOrder.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); + return paidOrder; + } +} From ccc2b0e4dae9284f0baf421559ae1ab898838540 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 6 Aug 2026 11:46:03 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=E5=A5=97=E9=A4=90=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../canteen/vo/app/DailyMenuAppVO.java | 87 +++++++++++++++---- .../CanteenDailyMenuController.java | 14 +++ .../mapper/CanteenDailyMenuMapper.java | 10 ++- .../service/CanteenDailyMenuService.java | 11 ++- .../impl/CanteenDailyMenuServiceImpl.java | 17 +++- .../mapper/CanteenDailyMenuMapper.xml | 32 ++++++- 6 files changed, 150 insertions(+), 21 deletions(-) diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/DailyMenuAppVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/DailyMenuAppVO.java index c5af151d8..ddff7d31a 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/DailyMenuAppVO.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/DailyMenuAppVO.java @@ -2,59 +2,112 @@ package com.bsd.pjcloud.canteen.vo.app; import lombok.Data; +import java.time.LocalDate; import java.util.List; +/** + * App 每日菜谱展示信息,聚合菜谱日期、餐段和关联菜品的详情字段。 + */ @Data public class DailyMenuAppVO { - + /** + * 每日菜谱记录 ID,是 App 详情接口和页面跳转使用的唯一标识。 + */ private Long id; /** - * 菜品id + * 菜谱生效日期。 + */ + private LocalDate startTime; + + /** + * 菜谱所属商户 ID。 + */ + private Long merchantId; + + /** + * 关联菜品 SPU ID,用于套餐组成查询和下单。 */ private Long spuId; /** - * 菜品名称 + * 菜品名称。 */ private String name; + /** - * 商品价格,单位使用:分 + * 商品销售价格,单位为分。 */ private Integer price; + /** - * 菜品分类编号 + * 商品市场价格,单位为分;为空时不展示划线价。 + */ + private Integer marketPrice; + + /** + * 菜品分类编号。 */ private Long categoryId; + /** - * 菜品封面图 + * 菜品分类名称;分类不存在或已删除时为空。 + */ + private String categoryName; + + /** + * 菜品封面图地址。 */ private String picUrl; + /** - * 库存 + * 商品轮播图地址,多个地址按逗号分隔。 + */ + private String sliderPicUrls; + + /** + * 商品简介;为空时详情页不展示简介区域。 + */ + private String introduction; + + /** + * 商品富文本详情;为空或仅包含空标签时详情页不展示图文区域。 + */ + private String description; + + /** + * 当前可售库存。 */ private Integer stock; /** - * 时间类型 + * 商品销量,仅用于详情页展示。 + */ + private Integer salesCount; + + /** + * 餐段类型:1 早餐、2 午餐、3 晚餐。 */ private Integer hourType; + /** + * 菜品展示排序,数值越小越靠前。 + */ private Integer sort; /** - * 是否显示 + * 是否显示,沿用菜品表协议值;App 查询只返回值为 0 的记录。 */ private String isShow; - /** - * 商品类型:1单品 2套餐 - */ - private Integer spuType; + /** + * 商品类型:1 单品、2 套餐。 + */ + private Integer spuType; - /** - * 套餐组成 - */ - private List comboItems; + /** + * 套餐组成;单品或套餐未配置明细时返回空列表。 + */ + private List comboItems; } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java index 6df7eaf5c..8b8698124 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java @@ -15,6 +15,7 @@ import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO; import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity; import com.bsd.pjcloud.canteen.service.CanteenSpuService; import com.bsd.pjcloud.canteen.vo.ADCanteenDailyMenuListVO; +import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity; @@ -212,6 +213,19 @@ public class CanteenDailyMenuController { return R.ok(canteenDailyMenuService.selectTimeFoodPage(page, dailyMenuDTO)); } + /** + * 按每日菜谱记录 ID 查询 App 详情。 + * + * @param id 每日菜谱记录 ID + * @return 每日菜谱详情;记录不存在、已删除或菜品已隐藏时 data 为空 + */ + @Operation(summary = "查询每日菜谱详情", description = "按每日菜谱记录 ID 查询 App 展示详情") + @ApiCryptoAes + @GetMapping("/app/{id}") + public R getAppDetail(@PathVariable("id") Long id) { + return R.ok(canteenDailyMenuService.getAppDetail(id)); + } + /** * 获取当天指定菜品 diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java index dcc9b3c59..5d64a0a52 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java @@ -34,4 +34,12 @@ public interface CanteenDailyMenuMapper extends PjcloudBaseMapper selectTimeFoodPage(@Param("page")Page page, DailyMenuDTO dailyMenuDTO); -} \ No newline at end of file + + /** + * 按每日菜谱记录 ID 查询 App 详情,结果聚合菜谱、菜品和分类的展示字段。 + * + * @param id 每日菜谱记录 ID + * @return 可展示的每日菜谱详情;记录不存在、已删除或菜品已隐藏时返回 null + */ + DailyMenuAppVO selectAppDetail(@Param("id") Long id); +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java index 90b7be021..7fa283a3c 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java @@ -7,6 +7,7 @@ import com.bsd.pjcloud.canteen.dto.DailyMenuAll; import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO; import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity; import com.bsd.pjcloud.canteen.vo.ADCanteenDailyMenuListVO; +import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.security.service.PjcloudUser; @@ -53,6 +54,14 @@ public interface CanteenDailyMenuService extends IService list) { if (CollectionUtils.isEmpty(list)) { return; @@ -249,4 +264,4 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl order by s.sort asc - \ No newline at end of file + + + From 5569212c95e4907ae4d9f86f5e9144b75ae2f523 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 6 Aug 2026 20:55:55 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat:=E8=81=9A=E5=90=88=E6=94=AF=E4=BB=98?= =?UTF-8?q?=20feat:=E6=94=B6=E9=93=B6=E7=BB=93=E7=AE=97=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/feign/RemoteCanteenSpuService.java | 11 ++ .../api/feign/RemoteDailyMenuService.java | 23 +-- .../CanteenDailyMenuController.java | 21 ++- .../controller/CanteenSpuController.java | 13 ++ .../mapper/CanteenDailyMenuMapper.java | 18 ++- .../service/CanteenDailyMenuService.java | 14 +- .../impl/CanteenDailyMenuServiceImpl.java | 16 +- .../mapper/CanteenDailyMenuMapper.xml | 43 ++++-- .../vo/android/ReservationMenuQueryVo.java | 33 ++++ .../impl/ReservationMenuQueryServiceImpl.java | 145 +++++++++++++++++- .../impl/order/HardOrderServiceImpl.java | 8 +- .../ReservationMenuQueryServiceImplTest.java | 67 +++++++- .../pay/ThirdPayOrderBizTypeEnum.java | 1 + .../ThirdAggregateOrderPayServiceImpl.java | 2 +- .../admin/handler/UserWalletCreateEvent.java | 48 ++++++ .../UserWalletCreateEventListener.java | 72 +++++++++ .../service/impl/SysUserServiceImpl.java | 25 +-- .../impl/user/SysUserRelationServiceImpl.java | 6 +- .../wallet/service/WalletActiveService.java | 21 +-- .../service/impl/WalletActiveServiceImpl.java | 13 ++ .../service/impl/WalletManageServiceImpl.java | 5 + 21 files changed, 518 insertions(+), 87 deletions(-) create mode 100644 pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/UserWalletCreateEvent.java create mode 100644 pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/UserWalletCreateEventListener.java diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java index d13d7849d..ef7974f08 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java @@ -23,6 +23,7 @@ package com.bsd.pjcloud.canteen.api.feign; import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO; import com.bsd.pjcloud.canteen.entity.CanteenOnlineSpuEntity; import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity; +import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO; import com.bsd.pjcloud.common.core.constant.ServiceNameConstants; import com.bsd.pjcloud.common.core.util.R; import org.springframework.cloud.openfeign.FeignClient; @@ -33,6 +34,7 @@ import org.springframework.web.bind.annotation.RequestParam; import java.util.Collection; import java.util.List; +import java.util.Map; /** * @author zjf @@ -67,6 +69,15 @@ public interface RemoteCanteenSpuService { @PostMapping("/canteenSpu/ad/getSpuListByIds") R> getSpuListByIds(@RequestBody List ids); + /** + * 按套餐 SPU ID 批量查询套餐组成,用于跨服务补充只读展示数据。 + * + * @param spuIds 套餐 SPU ID 列表,空列表返回空映射 + * @return 套餐 SPU ID 与已按配置顺序排列的组成菜品映射 + */ + @PostMapping("/canteenSpu/ad/getComboItemsBySpuIds") + R>> getComboItemsBySpuIds(@RequestBody List spuIds); + /** * 购物车效验库存 * @param spuId diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteDailyMenuService.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteDailyMenuService.java index fe2ed83f6..65f8ff68d 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteDailyMenuService.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteDailyMenuService.java @@ -26,9 +26,9 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO; import com.bsd.pjcloud.canteen.dto.DailyMenuAll; import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO; -import com.bsd.pjcloud.canteen.vo.ADCanteenDailyMenuListVO; import com.bsd.pjcloud.canteen.vo.APPOnlineSpuVO; import com.bsd.pjcloud.canteen.vo.app.CanteenOnlineSpuAppVO; +import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO; import com.bsd.pjcloud.common.core.constant.ServiceNameConstants; import com.bsd.pjcloud.common.core.util.R; import io.swagger.v3.oas.annotations.Operation; @@ -70,17 +70,18 @@ public interface RemoteDailyMenuService { /** - * 根据菜品日期获取当天的菜品列表 - * @param day 日期 - * @param hourType 早中晚 - * @param categoryId 分类id - * @param merchantId 商户id - * @return + * 查询收银终端指定日期的每日菜谱,返回与 App 分页记录一致的商品及套餐组成结构。 + * + * @param day 菜谱生效日期,格式为 yyyy-MM-dd + * @param hourType 餐段编码;为空表示全部餐段 + * @param categoryId 菜品分类 ID;为空表示全部分类 + * @param merchantId 收银终端所属商户 ID + * @return 指定条件下的每日菜谱列表;无可售菜品时 data 为空列表 */ @GetMapping("/canteenDailyMenu/ad/getCanteenList") - public R> getCanteenList(@RequestParam("day") String day, - @RequestParam(value = "hourType", required = false) Integer hourType, - @RequestParam(value = "categoryId", required = false) Long categoryId, - @RequestParam(value = "merchantId") Long merchantId); + R> getCanteenList(@RequestParam("day") String day, + @RequestParam(value = "hourType", required = false) Integer hourType, + @RequestParam(value = "categoryId", required = false) Long categoryId, + @RequestParam(value = "merchantId") Long merchantId); } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java index 8b8698124..aa54a4780 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java @@ -14,7 +14,6 @@ import com.bsd.pjcloud.canteen.dto.DailyMenuAll; import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO; import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity; import com.bsd.pjcloud.canteen.service.CanteenSpuService; -import com.bsd.pjcloud.canteen.vo.ADCanteenDailyMenuListVO; import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.log.annotation.SysLog; @@ -190,14 +189,22 @@ public class CanteenDailyMenuController { // ---------AD------------ + /** + * 查询收银终端指定日期的每日菜谱,并返回套餐类型及其组成明细。 + * + * @param day 菜谱生效日期,格式为 yyyy-MM-dd + * @param hourType 餐段编码;为空表示全部餐段 + * @param categoryId 菜品分类 ID;为空表示全部分类 + * @param merchantId 收银终端所属商户 ID + * @return 与 App 每日菜谱分页记录一致的商品列表;无数据时返回空列表 + */ @GetMapping("/ad/getCanteenList") @SysLog("查询每日菜品列表") - public R> getCanteenList(@RequestParam("day") String day, - @RequestParam(value = "hourType", required = false) Integer hourType, - @RequestParam(value = "categoryId", required = false) Long categoryId, - @RequestParam(value = "merchantId") Long merchantId) { - - return R.ok(canteenDailyMenuService.getCanteenMenuListScope(day, hourType, categoryId,merchantId)); + public R> getCanteenList(@RequestParam("day") String day, + @RequestParam(value = "hourType", required = false) Integer hourType, + @RequestParam(value = "categoryId", required = false) Long categoryId, + @RequestParam(value = "merchantId") Long merchantId) { + return R.ok(canteenDailyMenuService.getCanteenMenuListScope(day, hourType, categoryId, merchantId)); } // --------------APP--------------- diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java index 2a4f36ab3..4c4b6d5eb 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java @@ -15,6 +15,7 @@ import com.bsd.pjcloud.canteen.entity.CanteenOnlineSpuEntity; import com.bsd.pjcloud.canteen.entity.CanteenStockEntity; import com.bsd.pjcloud.canteen.mapper.CanteenStockMapper; import com.bsd.pjcloud.canteen.service.CanteenStockService; +import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO; import com.bsd.pjcloud.canteen.vo.erp.CanteenSpuSimpleVO; import com.bsd.pjcloud.canteen.vo.erp.CanteenSpuStockVO; import com.bsd.pjcloud.common.core.util.R; @@ -34,6 +35,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Map; import java.util.Objects; /** @@ -263,6 +265,17 @@ public class CanteenSpuController { return R.ok(list); } + /** + * 按套餐 SPU ID 批量查询套餐组成,供订单等内部业务补充只读展示信息。 + * + * @param spuIds 套餐 SPU ID 列表,空列表返回空映射 + * @return 套餐 SPU ID 与组成菜品列表的映射 + */ + @PostMapping("/ad/getComboItemsBySpuIds") + public R>> getComboItemsBySpuIds(@RequestBody List spuIds) { + return R.ok(canteenSpuService.loadComboItemVos(spuIds)); + } + @ApiCryptoAes @GetMapping("/app/checkSpuStock") diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java index 5d64a0a52..bbc607843 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java @@ -2,7 +2,6 @@ package com.bsd.pjcloud.canteen.mapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO; -import com.bsd.pjcloud.canteen.vo.ADCanteenDailyMenuListVO; import com.bsd.pjcloud.canteen.vo.DailyMenuVO; import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO; import com.bsd.pjcloud.common.data.datascope.DataScope; @@ -22,14 +21,17 @@ public interface CanteenDailyMenuMapper extends PjcloudBaseMapper getCanteenMenuList(@Param("day") String day, @Param("hourType") Integer hourType, - @Param("categoryId") Long categoryId, @Param("merchantId") Long merchantId,DataScope dataScope); + List getCanteenMenuList(@Param("day") String day, @Param("hourType") Integer hourType, + @Param("categoryId") Long categoryId, @Param("merchantId") Long merchantId, DataScope dataScope); diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java index 7fa283a3c..839ad25c1 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java @@ -6,7 +6,6 @@ import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuCVDTO; import com.bsd.pjcloud.canteen.dto.DailyMenuAll; import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO; import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity; -import com.bsd.pjcloud.canteen.vo.ADCanteenDailyMenuListVO; import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.security.service.PjcloudUser; @@ -43,14 +42,15 @@ public interface CanteenDailyMenuService extends IService getCanteenMenuListScope(String day, Integer hourType, Long categoryId, Long merchantId); + List getCanteenMenuListScope(String day, Integer hourType, Long categoryId, Long merchantId); Page selectTimeFoodPage(Page page, DailyMenuDTO dailyMenuDTO); diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java index b24e3a862..37edcf84e 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java @@ -11,7 +11,6 @@ import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO; import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity; import com.bsd.pjcloud.canteen.mapper.CanteenSpuMapper; import com.bsd.pjcloud.canteen.service.CanteenSpuService; -import com.bsd.pjcloud.canteen.vo.ADCanteenDailyMenuListVO; import com.bsd.pjcloud.canteen.vo.DailyMenuVO; import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity; import com.bsd.pjcloud.canteen.mapper.CanteenDailyMenuMapper; @@ -138,9 +137,20 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl getCanteenMenuListScope(String day, Integer hourType, Long categoryId, Long merchantId) { - return baseMapper.getCanteenMenuList(day, hourType, categoryId, merchantId, DataScope.of()); + public List getCanteenMenuListScope(String day, Integer hourType, Long categoryId, Long merchantId) { + List list = baseMapper.getCanteenMenuList(day, hourType, categoryId, merchantId, DataScope.of()); + fillDailyMenuAppCombo(list); + return list; } @Override diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml index d15159de9..593026e89 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml @@ -17,17 +17,26 @@ - + - + + - + + + + + + - + + + + - SELECT - sp.id as spu_id, t.id, + t.start_time, + t.merchant_id, + sp.id AS spu_id, sp.name, - sp.pic_url, - t.hour_type, sp.price, + sp.market_price, + sp.pic_url, + sp.slider_pic_urls, + sp.introduction, + sp.description, sp.stock, - sp.category_id, + sp.sales_count, sp.sort, + sp.is_show, + c.id AS category_id, + c.name AS category_name, + IFNULL(sp.spu_type, 1) AS spu_type, + t.hour_type, + t.dept_id, t.create_by FROM canteen_daily_menu t LEFT JOIN canteen_spu sp on t.spu_id=sp.id + LEFT JOIN canteen_category c + on sp.category_id = c.id AND c.del_flag = 0 - sp.del_flag = 0 and t.del_flag = 0 + sp.del_flag = 0 and t.del_flag = 0 and sp.is_show = 0 AND sp.category_id = #{categoryId} @@ -85,6 +107,7 @@ AND t.merchant_id = #{merchantId} + order by sp.sort asc