From d1cd86dfa412ca8e217872cc834ebdd782fc24ed Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Mon, 24 Aug 2026 18:39:59 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E9=87=8D=E6=9E=84"=E9=A3=9F?= =?UTF-8?q?=E5=A0=82=E8=8F=9C=E5=93=81=E8=B4=AD=E4=B9=B0=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?"+"=E9=A3=9F=E5=A0=82=E8=8F=9C=E5=93=81=E9=83=A8=E9=97=A8?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1"=E8=8F=9C=E5=8D=95=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E9=A2=84=E7=BA=A6=E6=A0=B8=E9=94=80=E8=AE=A2=E5=8D=95+?= =?UTF-8?q?=E7=BA=BF=E4=B8=8B=E8=B4=AD=E4=B9=B0=E8=AE=A2=E5=8D=95=20feat?= =?UTF-8?q?=EF=BC=9A=E5=A2=9E=E5=8A=A0=E6=9C=BA=E5=99=A8=E7=9A=84=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=8F=9C=E8=B0=B1=E5=BA=93=E5=AD=98=E6=89=A3=E6=AC=BE?= =?UTF-8?q?=20feat=EF=BC=9A=E5=A2=9E=E5=8A=A0=E8=AE=A2=E5=8D=95"=E9=80=80?= =?UTF-8?q?=E5=9B=9E=E5=88=B0=E6=A0=B8=E9=94=80=E5=89=8D"=E6=8C=89?= =?UTF-8?q?=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/20260824_add_third_payment_task.sql | 59 + .../order/HardCanteenStatisticsLevelEnum.java | 44 + .../constant/order/HardSaleSourceEnum.java | 57 + .../hard/api/dto/order/HardCarDto.java | 6 + .../hard/api/dto/order/HardOrderDto.java | 10 + .../vo/order/HardCanteenProductTotalVo.java | 22 + .../pjcloud/hard/api/vo/order/HardCarVo.java | 6 + .../hard/api/vo/order/HardOrderVo.java | 6 + .../excel/HardCanteenDeptTotalExcelVo.java | 54 + .../excel/HardCanteenProductTotalExcelVo.java | 46 + .../order/HardCanteenDeptTotalController.java | 23 +- .../order/HardCanteenTotalController.java | 40 + .../controller/order/HardOrderController.java | 14 + .../hard/mapper/order/HardCarMapper.java | 57 + .../hard/service/HardBroadcastService.java | 9 +- .../hard/service/HardCanteenTotalService.java | 28 +- .../hard/service/HardOrderService.java | 8 + .../impl/HardBroadcastServiceImpl.java | 67 + .../impl/order/HardAfterSaleServiceImpl.java | 65 +- .../order/HardCanteenTotalServiceImpl.java | 549 +++++++- .../impl/order/HardOrderServiceImpl.java | 227 +++- .../resources/mapper/order/HardCarMapper.xml | 386 ++++++ .../mapper/order/HardOrderMapper.xml | 40 + .../HardCanteenTotalServiceImplTest.java | 246 ++++ .../HardOrderServiceImplInventoryTest.java | 206 +++ ...rdOrderServiceImplPaymentCallbackTest.java | 57 + .../HardOrderServiceImplVerificationTest.java | 87 +- .../feign/RemoteThirdPaymentTaskService.java | 26 + .../pay/ThirdPaymentTaskStatusEnum.java | 35 + .../pay/ThirdPaymentTaskTypeEnum.java | 66 + .../dto/appplus/ThirdMiniRemotePayDto.java | 5 + .../dto/pay/ThirdPaymentTaskDispatchDto.java | 26 + .../pay/ThirdPaymentTaskAttemptEntity.java | 50 + .../entity/pay/ThirdPaymentTaskEntity.java | 82 ++ .../wechat/ThirdMiniWechatRefundQueryVo.java | 5 + .../thirdparty/vo/pay/ThirdPayOrderVo.java | 97 ++ .../vo/pay/ThirdPaymentTaskDispatchVo.java | 22 + .../thirdparty/config/AppPlusPayConfig.java | 23 + .../pay/ThirdPayOrderController.java | 50 +- .../pay/ThirdPaymentTaskController.java | 67 + .../job/ThirdAggregateOrderPayJob.java | 7 +- .../thirdparty/job/ThirdAppPlusPayJob.java | 364 ++++- .../pay/ThirdPaymentTaskAttemptMapper.java | 12 + .../mapper/pay/ThirdPaymentTaskMapper.java | 154 +++ .../service/ThirdAppPlusAliPayService.java | 56 + .../service/ThirdAppPlusService.java | 7 + .../service/ThirdAppPlusWechatFzService.java | 14 + .../service/ThirdAppPlusWechatService.java | 24 +- .../service/ThirdPayPollingCoordinator.java | 81 ++ .../ThirdPaymentOrderReservationService.java | 73 + .../service/ThirdPaymentTaskEnqueuer.java | 135 ++ .../service/ThirdPaymentTaskService.java | 32 + .../ThirdAggregateOrderPayServiceImpl.java | 72 +- .../ThirdAppPlusAliPayServiceImpl.java | 1190 ++++++++++++++++- .../impl/appplus/ThirdAppPlusServiceImpl.java | 454 +++++-- ...ThirdAppPlusWechatMicropayServiceImpl.java | 32 +- .../ThirdAppPlusWechatServiceImpl.java | 328 ++++- .../fz/ThirdAppPlusWechatFzServiceImpl.java | 346 ++++- .../ThirdAppPlusWechatFzLifecycle.java | 25 +- .../impl/pay/ThirdPayOrderServiceImpl.java | 439 +++++- .../impl/pay/ThirdPaymentTaskServiceImpl.java | 580 ++++++++ .../resources/mapper/ThirdPayOrderMapper.xml | 5 +- .../mapper/ThirdPaymentTaskMapper.xml | 324 +++++ .../job/ThirdAppPlusPayJobTest.java | 84 +- ...ThirdPaymentTaskMapperSqlContractTest.java | 85 ++ ...irdPaymentOrderReservationServiceTest.java | 70 + .../service/ThirdPaymentTaskEnqueuerTest.java | 75 ++ .../ThirdAppPlusAliPayServiceImplTest.java | 372 ++++++ ...irdAppPlusServiceImplOrderCodePayTest.java | 207 +++ .../ThirdAppPlusWechatFzServiceImplTest.java | 199 +++ ...pPlusWechatServiceImplPayCallbackTest.java | 2 + ...irdAppPlusWechatServiceImplRefundTest.java | 276 ++++ .../pay/ThirdPayOrderServiceImplTest.java | 204 +++ pjcloud-visual/pjcloud-daemon-quartz/pom.xml | 6 + .../config/ThirdPaymentQuartzInitializer.java | 96 ++ .../quartz/job/ThirdPaymentDispatchJob.java | 58 + .../src/main/resources/quartz-config.yml | 8 +- .../ThirdPaymentQuartzInitializerTest.java | 59 + .../api/entity/mch/MchPaySettingEntity.java | 15 +- .../api/feign/RemoteCodeUseService.java | 10 + .../controller/WalletCodeUseController.java | 12 + .../mch/MchPaySettingController.java | 2 +- .../wallet/service/MchPaySettingService.java | 45 +- .../wallet/service/WalletCodeUseService.java | 10 +- .../service/WalletTransactionService.java | 11 + .../impl/WalletCodeUseServiceImpl.java | 30 +- .../impl/WalletTransactionServiceImpl.java | 34 + .../impl/mch/MchPaySettingServiceImpl.java | 85 +- .../WalletRechargePackageServiceImpl.java | 10 +- .../impl/WalletCodeUseServiceImplTest.java | 92 ++ .../WalletTransactionServiceImplTest.java | 75 ++ .../mch/MchPaySettingServiceImplTest.java | 123 ++ 92 files changed, 9628 insertions(+), 454 deletions(-) create mode 100644 db/20260824_add_third_payment_task.sql create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardCanteenStatisticsLevelEnum.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardSaleSourceEnum.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenDeptTotalExcelVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductTotalExcelVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImplTest.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplInventoryTest.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplPaymentCallbackTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoteThirdPaymentTaskService.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPaymentTaskStatusEnum.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPaymentTaskTypeEnum.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdPaymentTaskDispatchDto.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdPaymentTaskAttemptEntity.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdPaymentTaskEntity.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPaymentTaskDispatchVo.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPaymentTaskController.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskAttemptMapper.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapper.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentOrderReservationService.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuer.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskService.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPaymentTaskServiceImpl.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapperSqlContractTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentOrderReservationServiceTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuerTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImplTest.java create mode 100644 pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializer.java create mode 100644 pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/job/ThirdPaymentDispatchJob.java create mode 100644 pjcloud-visual/pjcloud-daemon-quartz/src/test/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializerTest.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletCodeUseServiceImplTest.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java diff --git a/db/20260824_add_third_payment_task.sql b/db/20260824_add_third_payment_task.sql new file mode 100644 index 000000000..7fc7ded95 --- /dev/null +++ b/db/20260824_add_third_payment_task.sql @@ -0,0 +1,59 @@ +-- thirdparty 支付任务事实表与执行尝试表。 +-- Quartz 是唯一周期触发入口;任务表只保存可恢复事实、租约和幂等状态。 +CREATE TABLE IF NOT EXISTS `third_payment_task` +( + `id` bigint NOT NULL COMMENT '任务主键', + `tenant_id` bigint NOT NULL COMMENT '租户ID', + `task_type` varchar(48) NOT NULL COMMENT '任务类型编码', + `task_key` varchar(192) NOT NULL COMMENT '租户内稳定幂等任务键', + `pay_order_id` bigint DEFAULT NULL COMMENT 'third_pay_order主键', + `fz_order_id` bigint DEFAULT NULL COMMENT 'third_fz_order主键', + `aggregate_session_id` bigint DEFAULT NULL COMMENT 'third_aggregate_pay_session主键', + `pay_no` varchar(128) DEFAULT NULL COMMENT '平台支付单号', + `refund_no` varchar(128) DEFAULT NULL COMMENT '平台退款单号', + `fz_no` varchar(128) DEFAULT NULL COMMENT '平台分账单号', + `aggregate_no` varchar(128) DEFAULT NULL COMMENT '聚合会话单号', + `status` varchar(16) NOT NULL COMMENT '任务状态:READY/PROCESSING/RETRY/SUCCESS/FAILED/ALERT/CANCELLED', + `next_run_time` datetime NOT NULL COMMENT '下一次可执行时间', + `priority` int NOT NULL DEFAULT 0 COMMENT '任务优先级,数值越大越优先', + `lease_owner` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识', + `lease_token` varchar(64) DEFAULT NULL COMMENT '本次执行租约令牌', + `lease_expire_time` datetime DEFAULT NULL COMMENT '租约失效时间', + `attempt_count` int NOT NULL DEFAULT 0 COMMENT '累计执行次数', + `last_external_status` varchar(64) DEFAULT NULL COMMENT '最近一次渠道状态', + `last_error_code` varchar(128) DEFAULT NULL COMMENT '最近一次错误码', + `last_error_message` varchar(1024) DEFAULT NULL COMMENT '最近一次错误摘要', + `finished_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 '逻辑删除标记', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_third_payment_task_tenant_key` (`tenant_id`, `task_key`), + KEY `idx_third_payment_task_due` (`status`, `next_run_time`, `priority`, `id`), + KEY `idx_third_payment_task_lease` (`status`, `lease_expire_time`), + KEY `idx_third_payment_task_pay_no` (`tenant_id`, `pay_no`), + KEY `idx_third_payment_task_fz_no` (`tenant_id`, `fz_no`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务事实'; + +CREATE TABLE IF NOT EXISTS `third_payment_task_attempt` +( + `id` bigint NOT NULL COMMENT '尝试记录主键', + `task_id` bigint NOT NULL COMMENT '任务主键', + `tenant_id` bigint NOT NULL COMMENT '租户ID', + `attempt_no` int NOT NULL COMMENT '任务内尝试序号', + `trigger_source` varchar(32) NOT NULL COMMENT '触发来源:QUARTZ/CALLBACK/SYNC/MANUAL', + `worker_id` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识', + `lease_token` varchar(64) DEFAULT NULL COMMENT '执行租约令牌', + `request_snapshot` text DEFAULT NULL COMMENT '脱敏请求快照', + `response_snapshot` text DEFAULT NULL COMMENT '脱敏响应快照', + `external_status` varchar(64) DEFAULT NULL COMMENT '渠道状态', + `error_code` varchar(128) DEFAULT NULL COMMENT '错误码', + `error_message` varchar(1024) DEFAULT NULL COMMENT '错误摘要', + `started_time` datetime NOT NULL COMMENT '开始时间', + `finished_time` datetime DEFAULT NULL COMMENT '结束时间', + PRIMARY KEY (`id`), + KEY `idx_third_payment_attempt_task` (`task_id`, `attempt_no`), + KEY `idx_third_payment_attempt_tenant_time` (`tenant_id`, `started_time`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务执行尝试'; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardCanteenStatisticsLevelEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardCanteenStatisticsLevelEnum.java new file mode 100644 index 000000000..673352bb9 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardCanteenStatisticsLevelEnum.java @@ -0,0 +1,44 @@ +package com.bsd.pjcloud.hard.api.constant.order; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 食堂销售统计展示层级协议。 + * + *

该编码仅用于接口返回和管理端组织多维统计行,不对应数据库字段;调用方不得将名称作为稳定判断条件。

+ */ +@Getter +@AllArgsConstructor +public enum HardCanteenStatisticsLevelEnum { + + /** 按自然日汇总全部餐段和当前来源筛选范围。 */ + DATE_TOTAL("DATE_TOTAL", "当日合计"), + + /** 按自然日和餐段汇总当前来源筛选范围。 */ + HOUR_TOTAL("HOUR_TOTAL", "餐段合计"), + + /** 按自然日拆分来源,供当日合计行横向比较。 */ + DATE_SOURCE("DATE_SOURCE", "当日来源"), + + /** 按自然日、餐段拆分来源,供餐段合计行横向比较。 */ + HOUR_SOURCE("HOUR_SOURCE", "餐段来源"); + + /** 前后端接口使用的稳定层级编码,不写入数据库。 */ + private final String code; + + /** 层级的中文展示名称,仅用于界面说明。 */ + private final String description; + + /** + * 根据接口层级编码查找枚举。 + * + * @param code 管理端统计接口返回的层级编码;为空或未知时不匹配 + * @return 匹配的层级枚举,未匹配时返回 {@code null} + */ + public static HardCanteenStatisticsLevelEnum valueOfCode(String code) { + return Arrays.stream(values()).filter(value -> value.code.equals(code)).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardSaleSourceEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardSaleSourceEnum.java new file mode 100644 index 000000000..293faed04 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardSaleSourceEnum.java @@ -0,0 +1,57 @@ +package com.bsd.pjcloud.hard.api.constant.order; + +import lombok.Getter; + +import java.util.Arrays; +import java.util.List; + +/** + * 食堂销售统计来源。 + * + *

编码只用于统计查询和管理端展示,不写入订单表;来源由订单类型、预约关联、订单状态以及对应业务时间计算得到。

+ */ +@Getter +public enum HardSaleSourceEnum { + + /** 订单类型 1 的线下购买,统计时间取订单支付时间。 */ + OFFLINE_PURCHASE("OFFLINE_PURCHASE", "线下购买"), + + /** 订单类型 2 且已完成核销的预约,统计时间取订单核销时间。 */ + RESERVATION_VERIFIED("RESERVATION_VERIFIED", "预约核销"); + + /** 统计接口和前端标签页使用的来源编码。 */ + private final String code; + + /** 来源中文名称。 */ + private final String description; + + /** + * 创建稳定的统计来源协议项;编码只用于接口筛选,不对应数据库持久化值。 + * + * @param code 前后端共同使用的来源编码 + * @param description 管理端与导出使用的中文名称 + */ + HardSaleSourceEnum(String code, String description) { + this.code = code; + this.description = description; + } + + /** + * 返回全部可筛选来源编码。 + * + * @return 来源编码列表,顺序与管理端标签页一致 + */ + public static List getCodes() { + return Arrays.stream(values()).map(HardSaleSourceEnum::getCode).toList(); + } + + /** + * 按统计来源编码解析枚举。 + * + * @param code 统计接口传入的来源编码 + * @return 匹配的来源;为空或未知编码返回 {@code null} + */ + public static HardSaleSourceEnum valueOfCode(String code) { + return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java index 21b88f3e1..d5699377e 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java @@ -37,6 +37,12 @@ public class HardCarDto extends HardCarEntity implements BaseIsIncludeTreeInterf /** 预计使用日期范围。 */ private LocalDateTime[] apptUseDateTimeList; + /** 菜品销售统计日期范围,线下按支付时间、预约按核销时间过滤;不落库。 */ + private LocalDateTime[] statisticsDateTimeList; + + /** 菜品销售统计来源编码集合,取值来自 HardSaleSourceEnum;为空表示全部来源。 */ + private List saleSourceCodes; + /** * 前端树搜索 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java index 80487abf0..0a2829e9c 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java @@ -46,6 +46,16 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn private LocalDateTime[] payTimeList; + /** + * 食堂销售统计日期范围,线下来源按支付时间、预约来源按核销时间过滤;不落库。 + */ + private LocalDateTime[] statisticsDateTimeList; + + /** + * 食堂销售统计来源编码集合,取值来自 HardSaleSourceEnum;为空表示同时查询两种来源。 + */ + private List saleSourceCodes; + private LocalDateTime[] refundTimeList; /** 预计使用日期范围。 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductTotalVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductTotalVo.java index 3543aa620..d5af79b19 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductTotalVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductTotalVo.java @@ -42,9 +42,31 @@ public class HardCanteenProductTotalVo { @Schema(description = "购买次数") private Long timeCount; + /** 统计来源编码,来源于 HardSaleSourceEnum,不对应数据库字段。 */ + private String saleSource; + + /** 统计来源名称,用于管理端和导出展示,不对应数据库字段。 */ + private String saleSourceName; + + /** + * 统计展示层级,取值来自 HardCanteenStatisticsLevelEnum,不对应数据库字段。 + * 空值表示兼容旧统计接口返回结构。 + */ + private String statisticsLevel; + + /** 统计展示层级中文名称,用于管理端说明当前行为日期合计或餐段合计。 */ + private String statisticsLevelName; + + /** + * 当前合计行的来源拆分结果;管理端用它在同一行比较线下购买与预约核销。 + * 空集合表示当前筛选范围没有来源明细或属于旧统计接口。 + */ + private List sourceRows; + @Schema(description = "用餐部门总数") private Integer deptCount; + /** 菜品统计中承载餐段合计行,部门统计中承载部门餐段行;空值表示无下一级统计。 */ private List children; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java index 0ba972bd4..daa2fca5d 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java @@ -9,6 +9,12 @@ import lombok.Data; @Data public class HardCarVo extends HardCarEntity { + /** 统计明细来源编码,来源于 HardSaleSourceEnum,不对应购物车持久化字段。 */ + private String saleSource; + + /** 统计明细来源名称,用于管理端展示,不对应购物车持久化字段。 */ + private String saleSourceName; + /** 超市商品 SPU ID,仅超市统计和展示场景赋值。 */ private Long spuId; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java index 753f2b7cc..ffe257dd6 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java @@ -13,6 +13,12 @@ import java.util.List; @Data public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterface, BaseMchInterface, BaseHardInterface { + /** 统计明细来源编码;线下订单为 OFFLINE_PURCHASE,预约已核销订单为 RESERVATION_VERIFIED。 */ + private String saleSource; + + /** 统计明细来源名称,用于管理端明确区分支付和核销来源。 */ + private String saleSourceName; + /** * 用户相关 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenDeptTotalExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenDeptTotalExcelVo.java new file mode 100644 index 000000000..44132e023 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenDeptTotalExcelVo.java @@ -0,0 +1,54 @@ +package com.bsd.pjcloud.hard.api.vo.order.excel; + +import com.alibaba.excel.annotation.ExcelProperty; +import com.alibaba.excel.annotation.format.NumberFormat; +import com.alibaba.excel.annotation.write.style.ColumnWidth; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 食堂菜品部门销售统计导出行,按日期、食堂、来源、部门和餐段拆分。 + */ +@Data +@ColumnWidth(20) +public class HardCanteenDeptTotalExcelVo { + + /** 业务发生日期;线下取支付日期,预约取核销日期。 */ + @ExcelProperty("日期") + private LocalDate statisticsDate; + + /** 当前用户商户权限范围内的食堂名称。 */ + @ExcelProperty("食堂") + private String merchantName; + + /** 销售来源中文名称,取值为线下购买或预约核销。 */ + @ExcelProperty("来源") + private String saleSourceName; + + /** 下单用户所属部门名称;无法解析时为空。 */ + @ExcelProperty("部门") + private String userDeptName; + + /** 餐段字典名称,来源于 daily_menu。 */ + @ExcelProperty("餐段") + private String hourTypeName; + + /** 当前部门餐段内去重后的交易用户数量,单位为人。 */ + @ExcelProperty("交易人数") + private Long payUserCount; + + /** 当前部门餐段内去重后的订单数量,单位为次。 */ + @ExcelProperty("交易次数") + private Long timeCount; + + /** 退款扣减口径下的有效商品数量,单位为份。 */ + @ExcelProperty("商品数量") + private Long payCount; + + /** 有效商品总金额,单位由分转换为元。 */ + @ExcelProperty("总价(元)") + @NumberFormat("#,##0.00") + private BigDecimal totalPrice; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductTotalExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductTotalExcelVo.java new file mode 100644 index 000000000..8716e2d03 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductTotalExcelVo.java @@ -0,0 +1,46 @@ +package com.bsd.pjcloud.hard.api.vo.order.excel; + +import com.alibaba.excel.annotation.ExcelProperty; +import com.alibaba.excel.annotation.format.NumberFormat; +import com.alibaba.excel.annotation.write.style.ColumnWidth; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 食堂菜品销售统计导出行,按统计日期、餐段和销售来源拆分。 + */ +@Data +@ColumnWidth(20) +public class HardCanteenProductTotalExcelVo { + + /** 业务发生日期;线下取支付日期,预约取核销日期。 */ + @ExcelProperty("日期") + private LocalDate statisticsDate; + + /** 餐段字典名称,来源于 daily_menu。 */ + @ExcelProperty("餐段") + private String hourTypeName; + + /** 销售来源中文名称,取值为线下购买或预约核销。 */ + @ExcelProperty("来源") + private String saleSourceName; + + /** 来源内去重后的交易用户数量,单位为人。 */ + @ExcelProperty("交易人数") + private Long payUserCount; + + /** 来源内去重后的订单数量,单位为次。 */ + @ExcelProperty("交易次数") + private Long timeCount; + + /** 退款扣减口径下的有效商品数量,单位为份。 */ + @ExcelProperty("商品数量") + private Long payCount; + + /** 有效商品总金额,单位由分转换为元。 */ + @ExcelProperty("总价(元)") + @NumberFormat("#,##0.00") + private BigDecimal totalPrice; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenDeptTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenDeptTotalController.java index 4df49ca78..0b5094857 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenDeptTotalController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenDeptTotalController.java @@ -4,18 +4,20 @@ import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; +import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; -import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; -import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenDeptTotalExcelVo; import com.bsd.pjcloud.hard.service.HardCanteenTotalService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -44,8 +46,23 @@ public class HardCanteenDeptTotalController { PjcloudUser user = SecurityUtils.getUser(); dto.setTotalIsFindChildren(true); - dto.setApptIdOrApptRuleIdNotNull(true); return R.ok(canteenTotalService.listByDept(dto, user, dataScope)); } + /** + * 导出食堂部门销售统计,筛选和商户范围与部门统计主表一致。 + * + * @param dto 来源统计日期、来源、食堂、餐段和部门筛选 + * @return 按日期、食堂、来源、部门和餐段拆分的 Excel 行 + */ + @Operation(summary = "食堂菜品部门统计导出", description = "按日期、食堂、来源、部门和餐段导出销售统计") + @SysLog("食堂菜品部门统计导出") + @PreAuthorize("@pms.hasPermission('hard_canteenDeptTotal_export')") + @ResponseExcel(name = "食堂菜品部门统计") + @PostMapping("/export-dept") + public List exportDept(@RequestBody HardOrderDto dto) { + PjcloudUser user = SecurityUtils.getUser(); + return canteenTotalService.exportDept(dto, user, null); + } + } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java index 7c9266612..d0ae09808 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java @@ -7,6 +7,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; +import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.common.security.annotation.Inner; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; @@ -21,6 +23,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByCanteenVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByClassifyVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductLikeTopVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductTotalExcelVo; import com.bsd.pjcloud.hard.service.HardCanteenTotalService; import com.bsd.pjcloud.wallet.api.dto.MchManageDto; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; @@ -32,6 +35,7 @@ import lombok.Data; import lombok.RequiredArgsConstructor; import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.HttpHeaders; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -138,9 +142,45 @@ public class HardCanteenTotalController { return R.ok(canteenTotalService.productTotal(page, dto, null)); } + /** + * 导出食堂菜品销售统计,筛选范围与菜品统计主表完全一致。 + * + * @param dto 来源统计日期、来源、食堂、餐段和部门筛选 + * @return 按日期、餐段和来源拆分的 Excel 行 + */ + @Operation(summary = "食堂菜品销售统计导出", description = "按日期、餐段和来源导出食堂菜品销售统计") + @SysLog("食堂菜品销售统计导出") + @PreAuthorize("@pms.hasPermission('hard_canteenProductTotal_export')") + @ResponseExcel(name = "食堂菜品销售统计") + @PostMapping("/export-product") + public List exportProduct(@RequestBody HardOrderDto dto) { + MchManageDto mchManageDto = new MchManageDto(); + mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType())); + List mchList = remoteMchService.scopeList(mchManageDto).getData(); + if (ObjectUtil.isNotEmpty(mchList) && ObjectUtil.isEmpty(dto.getMchIds())) { + dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList()); + } + return canteenTotalService.exportProduct(dto, null); + } + + /** + * 查询菜品销售明细;新版来源统计补充当前用户可见食堂范围,旧查询仍沿用原接口行为。 + * + * @param page 分页参数 + * @param dto 商品名称、统计日期、来源、食堂、餐段和部门筛选 + * @return 按商品聚合的销售明细分页结果 + */ @Operation(summary = "菜品出售统计", description = "菜品出售统计") @PostMapping("/proudct-query") public R> proudctQuery(@ParameterObject Page page, @RequestBody HardCarDto dto) { + if (dto.getStatisticsDateTimeList() != null && ObjectUtil.isEmpty(dto.getMchIds())) { + MchManageDto mchManageDto = new MchManageDto(); + mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType())); + List mchList = remoteMchService.scopeList(mchManageDto).getData(); + if (ObjectUtil.isNotEmpty(mchList)) { + dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList()); + } + } return R.ok(canteenTotalService.proudctQuery(page, dto)); } 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 7bbedc38a..b9936a980 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 @@ -538,6 +538,20 @@ public class HardOrderController { return R.ok(hardwareOrderService.handCancelCode(body)); } + /** + * 将工作人员误核销的线上预订订单恢复到待核销状态,沿用手动核销权限控制。 + * + * @param orderId 已核销的线上预订订单主键 + * @return 订单及预约二维码均恢复成功时返回 {@code true} + */ + @Operation(summary = "admin退回线上预订核销", description = "将已核销的线上预订订单退回到待核销状态") + @PutMapping("/reserve-verification-rollback/{orderId}") + @Idempotent(expireTime = 1, key = "'/hard-order/reserve-verification-rollback' + #orderId ", info = "正在退回核销状态,请勿重复操作") + @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_cancel')") + public R rollbackReserveVerification(@PathVariable Long orderId) { + return R.ok(hardwareOrderService.rollbackReserveVerification(orderId)); + } + @Operation(summary = "分页查询", description = "分页查询") @PostMapping("/page") @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java index b7ec5a8bd..6fcabb890 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java @@ -36,6 +36,37 @@ public interface HardCarMapper extends PjcloudBaseMapper { Page productTotal(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope); + /** + * 按业务发生日期、餐段和来源分页统计菜品销售。 + * + * @param page 分页参数 + * @param dto 统计日期、来源、食堂、餐段和部门筛选 + * @param dataScope 订单所属部门数据范围;为空时不附加范围条件 + * @return 来源独立分组的菜品销售统计页 + */ + Page productTotalStats(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope); + + /** + * 查询菜品多维统计行且不应用数据库分页。 + * + *

同一次查询通过 ROLLUP 返回当日合计、餐段合计和餐段来源三层数据,保证交易人数在每个层级独立去重; + * 服务层按完整自然日分页,导出仅选择餐段来源层。

+ * + * @param dto 统计日期、来源、食堂、餐段和部门筛选 + * @param dataScope 订单所属部门数据范围;为空时不附加范围条件 + * @return 按日期、餐段和来源层级排序的多维统计行 + */ + List listProductTotalStats(@Param("dto") HardOrderDto dto, DataScope dataScope); + + /** + * 按当前主表行口径查询部门汇总,仅供新版菜品统计展开行使用。 + * + * @param dto 单日、餐段、来源、食堂和用户部门筛选 + * @param dataScope 订单所属部门数据范围;为空时不附加范围条件 + * @return 按用户部门拆分且在当前来源范围内独立去重的销售统计行 + */ + List productTotalByDeptStats(@Param("dto") HardOrderDto dto, DataScope dataScope); + Page productTotalByApptUseDate(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope); /** 按预约日期和餐段查询导出所需的日期餐段总计。 */ @@ -49,6 +80,15 @@ public interface HardCarMapper extends PjcloudBaseMapper { Page proudctQuery(Page page, @Param("dto") HardCarDto dto); + /** + * 按来源和业务发生时间分页查询菜品汇总明细。 + * + * @param page 分页参数 + * @param dto 单日、来源、食堂、餐段、部门和商品名称筛选 + * @return 按商品聚合的来源明细页 + */ + Page proudctQueryStats(Page page, @Param("dto") HardCarDto dto); + /** * 菜品出售商户统计 */ @@ -56,8 +96,25 @@ public interface HardCarMapper extends PjcloudBaseMapper { List listByDept(@Param("dto") HardOrderDto dto, DataScope dataScope); + /** + * 按日期和食堂查询部门统计合计行及其来源拆分行。 + * + * @param dto 统计日期、来源、食堂、餐段和用户部门筛选 + * @param dataScope 订单所属部门数据范围;为空时不附加范围条件 + * @return 日期食堂合计行和日期食堂来源行,服务层据此装配主从层级 + */ + List listByDeptStats(@Param("dto") HardOrderDto dto, DataScope dataScope); + List listByHourType(@Param("dto") HardOrderDto dto); + /** + * 按日期、部门和餐段查询展开合计行及其来源拆分行。 + * + * @param dto 单日、食堂、来源、餐段和用户部门筛选 + * @return 部门餐段合计行和部门餐段来源行,服务层据此装配主从层级 + */ + List listByHourTypeStats(@Param("dto") HardOrderDto dto); + /** * 按预约订单父购物车主键批量读取商品快照,供预约统计计算真实菜品份数。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBroadcastService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBroadcastService.java index 162427d93..42cab3c36 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBroadcastService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBroadcastService.java @@ -24,8 +24,15 @@ public interface HardBroadcastService extends IService { */ void doneBroadcast(HardOrderEntity orderdetail, UserInfo userInfo); + /** + * 将误核销预约订单的取餐广播恢复为待处理,并通知当前商户的取餐设备重新展示。 + * + * @param orderdetail 已由订单服务校验为线上预订且正在回退的订单 + */ + void rollbackDoneBroadcast(HardOrderEntity orderdetail); + /** * android完成一轮广播 */ void androidDoneBroadcast(HardAdBroadcastDto dto); -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java index 7dec6c5fc..45518495b 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java @@ -15,6 +15,8 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByCanteenVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByClassifyVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductLikeTopVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenDeptTotalExcelVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductTotalExcelVo; import java.util.List; import java.util.Map; @@ -42,7 +44,12 @@ public interface HardCanteenTotalService { List productTotalByDept(Page page, HardOrderDto dto, DataScope dataScope); /** - * 食堂菜品购买统计 + * 分页查询食堂菜品购买统计。 + * + * @param page 分页参数;来源统计模式下每页数量表示自然日数量 + * @param dto 统计日期、来源、食堂、餐段和部门筛选 + * @param dataScope 订单部门数据范围;为空时不追加数据范围条件 + * @return 当日合计和餐段合计行,来源统计模式下每行包含对应来源明细 */ Page productTotal(Page page, HardOrderDto dto, DataScope dataScope); @@ -60,4 +67,23 @@ public interface HardCanteenTotalService { * 食堂菜品购买部门统计 */ List listByDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope); + + /** + * 按页面相同筛选条件导出菜品销售统计。 + * + * @param dto 来源统计请求 + * @param dataScope 部门数据权限;为空时沿用控制器既有范围 + * @return 按日期、餐段和来源拆分的导出行 + */ + List exportProduct(HardOrderDto dto, DataScope dataScope); + + /** + * 按页面相同筛选条件导出部门销售统计。 + * + * @param dto 来源统计请求 + * @param user 当前登录用户,用于沿用商户范围查询 + * @param dataScope 部门数据权限;为空时沿用控制器既有范围 + * @return 按日期、食堂、来源、部门和餐段拆分的导出行 + */ + List exportDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope); } 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 91874f947..d5d02cba1 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 @@ -299,6 +299,14 @@ public interface HardOrderService extends IService { */ Boolean handCancelCode(HandCancelCodeDto dto); + /** + * 将误核销的线上预订订单恢复到待核销状态,并同步恢复对应预约二维码。 + * + * @param orderId 已核销的线上预订订单主键 + * @return 订单及二维码均恢复成功时返回 {@code true} + */ + Boolean rollbackReserveVerification(Long orderId); + /** * 核销二维码 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardBroadcastServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardBroadcastServiceImpl.java index f7cfbfe0a..0d70dd8a1 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardBroadcastServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardBroadcastServiceImpl.java @@ -214,6 +214,73 @@ public class HardBroadcastServiceImpl extends ServiceImpl() + .eq(HardBroadcastEntity::getBizId, orderdetail.getId()) + .orderByDesc(HardBroadcastEntity::getCreateTime) + .orderByDesc(HardBroadcastEntity::getId) + .last("limit 1")); + if (ObjectUtil.isEmpty(broadcastDetail)) return; + baseMapper.update(null, new LambdaUpdateWrapper() + .set(HardBroadcastEntity::getIsBroadcast, YesNoEnum.YES.getCode()) + .set(HardBroadcastEntity::getIsDone, YesNoEnum.NO.getCode()) + .set(HardBroadcastEntity::getIsDoneTime, null) + .eq(HardBroadcastEntity::getBizId, String.valueOf(orderdetail.getId()))); + + UserInfo userInfo; + try { + R userResult = remoteUserService.getInnerByUserId(orderdetail.getUserId()); + userInfo = userResult == null ? null : userResult.getData(); + } catch (Exception exception) { + log.warn("|{}|恢复预约广播时查询用户失败,orderId:|{}|,errorType:|{}|", TAG, + orderdetail.getId(), exception.getClass().getSimpleName()); + return; + } + if (userInfo == null || userInfo.getSysUser() == null) return; + List hardList = hardManagerMapper.selectList(new LambdaQueryWrapper() + .eq(HardManagerEntity::getMerchantId, orderdetail.getMerchantId()) + .eq(HardManagerEntity::getIsTakeFood, YesNoEnum.YES.getCode())); + if (ObjectUtil.isEmpty(hardList)) return; + for (HardManagerEntity hard : hardList) { + try { + String topic = hard.getMainTopic().replaceAll("/#", ""); + HardTakeFoodBroadcastMsg msg = new HardTakeFoodBroadcastMsg(); + msg.setTopic(topic); + msg.setMchDeptId(hard.getDeptId()); + msg.setHardwareId(hard.getId()); + msg.setAction(MqttActionEnum.TAKE_FOOD_BROADCAST); + msg.setUserId(String.valueOf(userInfo.getSysUser().getUserId())); + msg.setUsername(userInfo.getSysUser().getName()); + msg.setUserMobile(userInfo.getSysUser().getPhone()); + msg.setId(String.valueOf(broadcastDetail.getId())); + msg.setOrderId(String.valueOf(orderdetail.getId())); + msg.setTitle(broadcastDetail.getTitle()); + msg.setContent(broadcastDetail.getContent()); + msg.setBroadcastMaxCount(broadcastDetail.getBroadcastMaxCount()); + msg.setProductDesc(orderdetail.getProductDesc()); + msg.setPayPrice(orderdetail.getPayPrice()); + msg.setHourType(orderdetail.getHourType()); + msg.setIsDone(YesNoEnum.NO.getCode()); + remoteMqttEventService.sendTakeFoodBroadcast(msg); + } catch (Exception exception) { + log.warn("|{}|恢复预约广播实时通知失败,orderId:|{}|,hardId:|{}|,errorType:|{}|", TAG, + orderdetail.getId(), hard.getId(), exception.getClass().getSimpleName()); + } + } + } + /** * android完成一轮广播 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java index ead9a8d65..98ca45dd0 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java @@ -23,6 +23,7 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum; +import com.bsd.pjcloud.hard.api.constant.HardProductSourceEnum; 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; @@ -656,12 +657,13 @@ public class HardAfterSaleServiceImpl extends ServiceImpl carList = dto.getCarList().stream().map((item) -> HardCarEntity.builder() .productId(item.getProductId()) @@ -1813,18 +1816,38 @@ public class HardAfterSaleServiceImpl extends ServiceImpl售后商品快照是服务端核定的可信数量:存在快照时仅恢复本次部分退款商品, * 快照为空时兼容整单退款并恢复父购物车全部商品。库存服务使用售后单 ID 做幂等隔离, * 使重复回调不会重复增加库存。

* - * @param order 原每日菜谱预约订单 + * @param order 原每日菜谱预约订单或机器收银订单 * @param afterSaleId 本次售后单 ID,不能为空 */ private void restoreRefundInventory(HardOrderEntity order, Long afterSaleId) { if (afterSaleId == null) { - throw new RuntimeException("每日菜谱退款库存缺少售后单 ID"); + throw new RuntimeException("食堂菜品退款库存缺少售后单 ID"); } List refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId); if (ObjectUtil.isEmpty(refundCars)) { @@ -2072,13 +2095,13 @@ public class HardAfterSaleServiceImpl extends ServiceImpl productTotalByDept(Page page, HardOrderDto dto, DataScope dataScope) { dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes()); - List list = hardCarMapper.productTotalByDept(dto, dataScope); + List list; + if (isSourceStatistics(dto)) { + normalizeSaleSources(dto); + list = hardCarMapper.productTotalByDeptStats(dto, dataScope); + } else { + list = hardCarMapper.productTotalByDept(dto, dataScope); + } + fillSaleSourceFields(list); hardCanteenProductTotalVoSetDeptData(list); return list; } /** - * 食堂菜品购买统计 + * 分页查询食堂菜品购买统计。 + * + *

新版来源统计先读取日期、餐段和来源的完整多维结果,再按自然日分页装配为“当日合计 -> 餐段合计”, + * 每条合计行携带对应来源明细,避免来源行占据主表分组后无法查看整体数据。旧调用方未传统计日期时仍使用原 SQL。

+ * + * @param page 分页参数;新版统计的每页数量表示自然日数量 + * @param dto 统计日期、来源、食堂、餐段和部门筛选 + * @param dataScope 订单部门数据范围;为空时不追加数据权限条件 + * @return 按自然日分页且包含餐段和来源明细的统计结果 */ @Override public Page productTotal(Page page, HardOrderDto dto, DataScope dataScope) { dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes()); - Page pageList = hardCarMapper.productTotal(page, dto, null); + Page pageList; + if (isSourceStatistics(dto)) { + normalizeSaleSources(dto); + List rows = hardCarMapper.listProductTotalStats(dto, dataScope); + boolean includeSourceRows = dto.getSaleSourceCodes().size() > 1; + pageList = assembleProductStatisticsPage(page, rows, ObjectUtil.isNotEmpty(dto.getHourType()), includeSourceRows); + } else { + pageList = hardCarMapper.productTotal(page, dto, dataScope); + } if (ObjectUtil.isNotEmpty(pageList)) { for (HardCanteenProductTotalVo v : pageList.getRecords()) { - if (ObjectUtil.isEmpty(v.getCreateTime())) continue; - LocalDate localDate = v.getCreateTime().toLocalDate(); - v.setUniqueCode(localDate + "_" + v.getHourType()); + fillSaleSourceFields(v); + if (ObjectUtil.isNotEmpty(v.getCreateTime())) { + LocalDate localDate = v.getCreateTime().toLocalDate(); + v.setUniqueCode(localDate + "_" + ObjectUtil.defaultIfNull(v.getStatisticsLevel(), "LEGACY") + "_" + + ObjectUtil.defaultIfNull(v.getHourType(), "ALL")); + } } } return pageList; } + /** + * 将 Mapper 的多维统计行装配为按自然日分页的主表结果。 + * + *

交易人数等合计值直接使用对应数据库分组结果,禁止通过来源行相加生成;来源行只作为横向对比明细挂到合计行。 + * 选择具体餐段时隐藏内容相同的当日合计,避免页面出现两条重复统计。

+ * + * @param requestedPage 调用方请求的页码和每页自然日数量 + * @param rows 数据库返回的当日合计、当日来源、餐段合计和餐段来源行 + * @param hourTypeFiltered 是否已经按单一餐段筛选 + * @param includeSourceRows 是否挂载来源拆分行;仅“全部”来源视图需要展示,单来源视图禁用以避免重复行 + * @return 仅包含当前页自然日的合计行,每行附带来源对比数据 + */ + private Page assembleProductStatisticsPage(Page requestedPage, + List rows, + boolean hourTypeFiltered, + boolean includeSourceRows) { + Page result = new Page<>(requestedPage.getCurrent(), requestedPage.getSize()); + if (ObjectUtil.isEmpty(rows)) { + result.setTotal(0); + result.setRecords(List.of()); + return result; + } + + List sourceRows = rows.stream() + .filter(row -> ObjectUtil.isNotEmpty(row.getSaleSource())) + .toList(); + fillSaleSourceFields(sourceRows); + sourceRows.forEach(this::fillStatisticsLevelName); + Map> sourceRowsByScope = sourceRows.stream() + .collect(Collectors.groupingBy(this::statisticsScopeKey, LinkedHashMap::new, Collectors.toList())); + + List totalRows = rows.stream() + .filter(row -> ObjectUtil.isEmpty(row.getSaleSource())) + .filter(row -> !hourTypeFiltered + || ObjectUtil.equals(row.getStatisticsLevel(), HardCanteenStatisticsLevelEnum.HOUR_TOTAL.getCode())) + .toList(); + List statisticsDates = totalRows.stream() + .map(HardCanteenProductTotalVo::getCreateTime) + .filter(ObjectUtil::isNotEmpty) + .map(LocalDateTime::toLocalDate) + .distinct() + .toList(); + + long fromIndex = Math.min((requestedPage.getCurrent() - 1) * requestedPage.getSize(), statisticsDates.size()); + long toIndex = Math.min(fromIndex + requestedPage.getSize(), statisticsDates.size()); + Set currentDates = new HashSet<>(statisticsDates.subList((int) fromIndex, (int) toIndex)); + List currentRows = totalRows.stream() + .filter(row -> row.getCreateTime() != null && currentDates.contains(row.getCreateTime().toLocalDate())) + .peek(row -> { + HardCanteenStatisticsLevelEnum level = HardCanteenStatisticsLevelEnum.valueOfCode(row.getStatisticsLevel()); + row.setStatisticsLevelName(level == null ? null : level.getDescription()); + row.setSourceRows(includeSourceRows + ? sourceRowsByScope.getOrDefault(statisticsScopeKey(row), List.of()) + : List.of()); + }) + .toList(); + result.setTotal(statisticsDates.size()); + result.setRecords(currentRows); + return result; + } + + /** + * 构造当日或餐段统计的来源挂载键。 + * + * @param row 合计行或来源行;日期为空时返回稳定空日期片段 + * @return “日期_餐段”键,当日层级的餐段片段固定为 ALL + */ + private String statisticsScopeKey(HardCanteenProductTotalVo row) { + String statisticsDate = row == null || row.getCreateTime() == null + ? "UNKNOWN" : row.getCreateTime().toLocalDate().toString(); + return statisticsDate + "_" + ObjectUtil.defaultIfNull(row == null ? null : row.getHourType(), "ALL"); + } + /** * 菜品出售统计 */ @Override public Page proudctQuery(Page page, HardCarDto dto) { dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes()); + if (isSourceStatistics(dto)) { + normalizeSaleSources(dto); + Page result = hardCarMapper.proudctQueryStats(page, dto); + fillSaleSourceFields(result == null ? null : result.getRecords()); + return result; + } return hardCarMapper.proudctQuery(page, dto); } @@ -219,7 +337,16 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { } /** - * 食堂菜品购买部门统计 + * 查询食堂菜品部门统计。 + * + *

新版来源统计以“日期 + 食堂”的跨来源合计作为主行,并将来源拆分挂载到主行;展开后再以 + * “部门 + 餐段”的跨来源合计为主行并挂载来源拆分。所有合计均来自数据库独立聚合,避免通过 + * 来源行相加导致交易人数重复。未传统计日期范围时继续使用旧查询结构。

+ * + * @param dto 统计日期、来源、食堂、餐段和部门筛选;totalIsFindChildren 决定是否查询展开明细 + * @param user 当前登录用户,保留给既有商户范围契约 + * @param dataScope 订单所属部门数据范围;为空时不附加范围条件 + * @return 食堂部门统计主行;新版主行附带来源拆分和部门餐段明细 */ @Override public List listByDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope) { @@ -231,30 +358,56 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { List mchList = remoteMchService.scopeList(mchListDto).getData(); if (ObjectUtil.isEmpty(mchList)) return null; dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList()); - List list = hardCarMapper.listByDept(dto, dataScope); + boolean sourceStatistics = isSourceStatistics(dto); + if (sourceStatistics) normalizeSaleSources(dto); + List mapperRows = sourceStatistics + ? hardCarMapper.listByDeptStats(dto, dataScope) + : hardCarMapper.listByDept(dto, dataScope); + boolean includeSourceRows = sourceStatistics && dto.getSaleSourceCodes().size() > 1; + List list = sourceStatistics + ? assembleDepartmentMainRows(mapperRows, includeSourceRows) + : mapperRows; + fillSaleSourceFields(list); if (ObjectUtil.isNotEmpty(list) && ObjectUtil.isNotEmpty(dto.getTotalIsFindChildren()) && dto.getTotalIsFindChildren()) { // 限制最大线程数为10 int threadCount = Math.min(list.size(), 10); - ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + ExecutorService executorService = threadCount > 0 ? Executors.newFixedThreadPool(threadCount) : null; try { List> futures = list.stream() .map(v -> CompletableFuture.runAsync(() -> { // 查询时间段 HardOrderDto deptQuery = BeanUtil.toBean(dto, HardOrderDto.class); deptQuery.setMerchantId(v.getMchId()); - List deptList = hardCarMapper.listByHourType(deptQuery); + if (sourceStatistics) { + deptQuery.setCreateTimeStr(v.getCreateTime() == null ? null : v.getCreateTime().toLocalDate().toString()); + deptQuery.setOrderApptUseDateStr(null); + } + List deptMapperRows = sourceStatistics + ? hardCarMapper.listByHourTypeStats(deptQuery) + : hardCarMapper.listByHourType(deptQuery); + List deptList = sourceStatistics + ? assembleDepartmentChildRows(deptMapperRows, v.getMchId(), includeSourceRows) + : deptMapperRows; for (HardCanteenProductTotalVo j : deptList) { if (ObjectUtil.isNotEmpty(v.getMchId())) j.setMchId(v.getMchId()); + fillSaleSourceFields(j); } - v.setDeptCount(deptList.stream().map(HardCanteenProductTotalVo::getUserDeptId).distinct().toList().size()); + if (!sourceStatistics) { + v.setDeptCount(deptList.stream().map(HardCanteenProductTotalVo::getUserDeptId).distinct().toList().size()); + } v.setChildren(deptList); + if (sourceStatistics && includeSourceRows) { + for (HardCanteenProductTotalVo sourceRow : v.getSourceRows()) { + sourceRow.setChildren(departmentSourceChildren(deptList, sourceRow.getSaleSource())); + } + } }, executorService)) .toList(); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); } finally { - executorService.shutdown(); + if (executorService != null) executorService.shutdown(); } } @@ -262,9 +415,381 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { hardCanteenProductTotalVoSetMchData(list); // 添加部门信息 hardCanteenProductTotalVoSetDeptData(list); + if (sourceStatistics) inheritDepartmentRowContext(list); return list; } + /** + * 将部门统计 Mapper 主行装配为“日期 + 食堂合计 -> 来源拆分”的展示结构。 + * + * @param rows Mapper 返回的日期食堂合计行和日期食堂来源行 + * @param includeSourceRows 是否挂载来源拆分;仅多来源视图需要挂载 + * @return 日期食堂合计行列表,顺序沿用 Mapper 的日期和食堂排序 + */ + private List assembleDepartmentMainRows(List rows, + boolean includeSourceRows) { + if (ObjectUtil.isEmpty(rows)) return List.of(); + List sourceRows = rows.stream() + .filter(row -> ObjectUtil.isNotEmpty(row.getSaleSource())) + .toList(); + fillSaleSourceFields(sourceRows); + sourceRows.forEach(this::fillStatisticsLevelName); + Map> sourceRowsByScope = sourceRows.stream() + .collect(Collectors.groupingBy(this::departmentMainScopeKey, LinkedHashMap::new, Collectors.toList())); + return rows.stream() + .filter(row -> ObjectUtil.isEmpty(row.getSaleSource())) + .peek(row -> { + fillStatisticsLevelName(row); + row.setSourceRows(includeSourceRows + ? sourceRowsByScope.getOrDefault(departmentMainScopeKey(row), List.of()) + : List.of()); + }) + .toList(); + } + + /** + * 将部门展开 Mapper 行装配为“部门 + 餐段合计 -> 来源拆分”的展示结构。 + * + * @param rows Mapper 返回的部门餐段合计行和部门餐段来源行 + * @param merchantId 当前展开食堂主键,用于明细按钮继续继承食堂范围 + * @param includeSourceRows 是否挂载来源拆分;仅多来源视图需要挂载 + * @return 部门餐段合计行列表 + */ + private List assembleDepartmentChildRows(List rows, + Long merchantId, + boolean includeSourceRows) { + if (ObjectUtil.isEmpty(rows)) return List.of(); + rows.forEach(row -> row.setMchId(merchantId)); + List sourceRows = rows.stream() + .filter(row -> ObjectUtil.isNotEmpty(row.getSaleSource())) + .toList(); + fillSaleSourceFields(sourceRows); + Map> sourceRowsByScope = sourceRows.stream() + .collect(Collectors.groupingBy(this::departmentChildScopeKey, LinkedHashMap::new, Collectors.toList())); + return rows.stream() + .filter(row -> ObjectUtil.isEmpty(row.getSaleSource())) + .peek(row -> { + fillStatisticsLevelName(row); + row.setSourceRows(includeSourceRows + ? sourceRowsByScope.getOrDefault(departmentChildScopeKey(row), List.of()) + : List.of()); + }) + .toList(); + } + + /** + * 从餐段合计行中提取指定来源的部门餐段行,供主表来源行展开时复用同一批查询结果。 + * + * @param totalRows 部门餐段合计行,每行可能挂载来源拆分 + * @param saleSource 主表来源行对应的稳定来源编码 + * @return 指定来源的部门餐段行;没有匹配数据时返回空列表 + */ + private List departmentSourceChildren(List totalRows, + String saleSource) { + if (ObjectUtil.isEmpty(totalRows) || ObjectUtil.isEmpty(saleSource)) return List.of(); + return totalRows.stream() + .flatMap(row -> row.getSourceRows().stream()) + .filter(row -> ObjectUtil.equals(row.getSaleSource(), saleSource)) + .toList(); + } + + /** + * 构造部门主表合计行和来源行的挂载键。 + * + * @param row 日期食堂统计行 + * @return “日期_食堂”稳定键;空值使用占位片段避免空指针 + */ + private String departmentMainScopeKey(HardCanteenProductTotalVo row) { + String statisticsDate = row == null || row.getCreateTime() == null + ? "UNKNOWN" : row.getCreateTime().toLocalDate().toString(); + return statisticsDate + "_" + ObjectUtil.defaultIfNull(row == null ? null : row.getMchId(), -1L); + } + + /** + * 构造部门展开合计行和来源行的挂载键。 + * + * @param row 部门餐段统计行 + * @return “日期_部门_餐段”稳定键;空值使用占位片段避免空指针 + */ + private String departmentChildScopeKey(HardCanteenProductTotalVo row) { + String statisticsDate = row == null || row.getCreateTime() == null + ? "UNKNOWN" : row.getCreateTime().toLocalDate().toString(); + return statisticsDate + "_" + ObjectUtil.defaultIfNull(row == null ? null : row.getUserDeptId(), -1L) + + "_" + ObjectUtil.defaultIfNull(row == null ? null : row.getHourType(), "ALL"); + } + + /** + * 补充统计层级中文名称,来源编码为空时仍保留合计层级语义。 + * + * @param row Mapper 返回的统计行;层级为空时保持兼容旧接口 + */ + private void fillStatisticsLevelName(HardCanteenProductTotalVo row) { + if (row == null || ObjectUtil.isEmpty(row.getStatisticsLevel())) return; + HardCanteenStatisticsLevelEnum level = HardCanteenStatisticsLevelEnum.valueOfCode(row.getStatisticsLevel()); + if (level != null) row.setStatisticsLevelName(level.getDescription()); + } + + /** + * 递归将合计行的食堂、部门、日期和餐段上下文补充到来源拆分行。 + * + *

来源行来自独立聚合 SQL,只携带分组键;页面把它展开为可直接操作的普通行,因此必须继承名称和 + * 查询范围,保证购买人员与菜品明细继续使用当前食堂、部门和餐段。

+ * + * @param rows 已完成食堂和部门名称填充的合计行列表 + */ + private void inheritDepartmentRowContext(List rows) { + if (ObjectUtil.isEmpty(rows)) return; + for (HardCanteenProductTotalVo row : rows) { + inheritDepartmentRowContext(row, row.getChildren()); + inheritDepartmentRowContext(row, row.getSourceRows()); + } + } + + /** + * 将父级业务维度复制到缺少对应值的子级统计行,并继续处理其下级数据。 + * + * @param parent 提供食堂、部门、日期和餐段上下文的父级统计行 + * @param children 待补充上下文的合计行或来源拆分行 + */ + private void inheritDepartmentRowContext(HardCanteenProductTotalVo parent, + List children) { + if (parent == null || ObjectUtil.isEmpty(children)) return; + for (HardCanteenProductTotalVo child : children) { + if (child.getMchId() == null) child.setMchId(parent.getMchId()); + if (ObjectUtil.isEmpty(child.getMchName())) child.setMchName(parent.getMchName()); + if (child.getUserDeptId() == null) child.setUserDeptId(parent.getUserDeptId()); + if (ObjectUtil.isEmpty(child.getUserDeptName())) child.setUserDeptName(parent.getUserDeptName()); + if (child.getCreateTime() == null) child.setCreateTime(parent.getCreateTime()); + if (ObjectUtil.isEmpty(child.getHourType())) child.setHourType(parent.getHourType()); + inheritDepartmentRowContext(child, child.getChildren()); + inheritDepartmentRowContext(child, child.getSourceRows()); + } + } + + /** + * 导出菜品统计,直接复用来源统计 SQL,确保导出与主表的业务发生时间口径一致。 + * + * @param dto 来源统计请求 + * @param dataScope 部门数据权限 + * @return Excel 导出行 + */ + @Override + public List exportProduct(HardOrderDto dto, DataScope dataScope) { + if (!isSourceStatistics(dto)) { + throw new IllegalArgumentException("导出必须选择统计日期范围"); + } + normalizeSaleSources(dto); + List rows = hardCarMapper.listProductTotalStats(dto, dataScope).stream() + .filter(row -> ObjectUtil.equals(row.getStatisticsLevel(), HardCanteenStatisticsLevelEnum.HOUR_SOURCE.getCode())) + .toList(); + fillSaleSourceFields(rows); + Map hourTypeNames = getHourTypeNames(); + if (ObjectUtil.isEmpty(rows)) return List.of(); + return rows.stream().map(row -> { + HardCanteenProductTotalExcelVo excel = new HardCanteenProductTotalExcelVo(); + excel.setStatisticsDate(row.getCreateTime() == null ? null : row.getCreateTime().toLocalDate()); + excel.setHourTypeName(hourTypeNames.getOrDefault(row.getHourType(), row.getHourType())); + excel.setSaleSourceName(row.getSaleSourceName()); + excel.setPayUserCount(row.getPayUserCount()); + excel.setTimeCount(row.getTimeCount()); + excel.setPayCount(row.getPayCount()); + excel.setTotalPrice(centsToYuan(row.getTotalPrice())); + return excel; + }).toList(); + } + + /** + * 导出部门统计,复用部门主表及子行装配结果后扁平化为 Excel 行。 + * + * @param dto 来源统计请求 + * @param user 当前登录用户 + * @param dataScope 部门数据权限 + * @return Excel 导出行 + */ + @Override + public List exportDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope) { + if (!isSourceStatistics(dto)) { + throw new IllegalArgumentException("导出必须选择统计日期范围"); + } + dto.setTotalIsFindChildren(true); + List rows = listByDept(dto, user, dataScope); + if (ObjectUtil.isEmpty(rows)) return List.of(); + Map hourTypeNames = getHourTypeNames(); + List result = new ArrayList<>(); + for (HardCanteenProductTotalVo parent : rows) { + if (ObjectUtil.isEmpty(parent.getChildren())) continue; + for (HardCanteenProductTotalVo child : parent.getChildren()) { + List exportRows = ObjectUtil.isNotEmpty(child.getSourceRows()) + ? child.getSourceRows() : List.of(child); + for (HardCanteenProductTotalVo exportRow : exportRows) { + HardCanteenDeptTotalExcelVo excel = new HardCanteenDeptTotalExcelVo(); + excel.setStatisticsDate(parent.getCreateTime() == null ? null : parent.getCreateTime().toLocalDate()); + excel.setMerchantName(parent.getMchName()); + excel.setSaleSourceName(resolveDepartmentExportSourceName(exportRow, dto)); + excel.setUserDeptName(exportRow.getUserDeptName()); + excel.setHourTypeName(hourTypeNames.getOrDefault(exportRow.getHourType(), exportRow.getHourType())); + excel.setPayUserCount(exportRow.getPayUserCount()); + excel.setTimeCount(exportRow.getTimeCount()); + excel.setPayCount(exportRow.getPayCount()); + excel.setTotalPrice(centsToYuan(exportRow.getTotalPrice())); + result.add(excel); + } + } + } + return result; + } + + /** + * 解析部门导出的具体销售来源名称。 + * + *

多来源导出使用来源拆分行自身名称;单来源查询为避免页面重复行不会挂载来源拆分, + * 此时使用已经校验过的请求来源补全名称。

+ * + * @param row 待导出的部门餐段统计行 + * @param dto 已完成来源标准化的统计请求 + * @return 可追溯的具体来源中文名称;来源异常时返回空值 + */ + private String resolveDepartmentExportSourceName(HardCanteenProductTotalVo row, HardOrderDto dto) { + if (row != null && ObjectUtil.isNotEmpty(row.getSaleSourceName())) return row.getSaleSourceName(); + if (dto == null || ObjectUtil.isEmpty(dto.getSaleSourceCodes()) || dto.getSaleSourceCodes().size() != 1) return null; + HardSaleSourceEnum source = HardSaleSourceEnum.valueOfCode(dto.getSaleSourceCodes().get(0)); + return source == null ? null : source.getDescription(); + } + + /** + * 读取餐段字典并生成编码到名称的映射;远程数据为空时导出保留原编码。 + * + * @return 餐段编码与中文名称映射 + */ + private Map getHourTypeNames() { + try { + List items = remoteDictService.getDictByType("daily_menu").getData(); + if (ObjectUtil.isEmpty(items)) return Map.of(); + return items.stream().filter(item -> ObjectUtil.isNotEmpty(item.getItemValue()) && ObjectUtil.isNotEmpty(item.getLabel())) + .collect(Collectors.toMap(SysDictItem::getItemValue, SysDictItem::getLabel, (left, right) -> left)); + } catch (Exception exception) { + log.warn("{}:读取餐段字典失败,导出将保留餐段编码", TAG, exception); + return Map.of(); + } + } + + /** + * 将持久化使用的分转换为导出展示使用的元,空金额按零处理。 + * + * @param cents 金额,单位为分 + * @return 保留两位小数的元金额 + */ + private BigDecimal centsToYuan(Long cents) { + return BigDecimal.valueOf(ObjectUtil.defaultIfNull(cents, 0L)).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + } + + /** + * 判断是否启用来源统计查询。未传统计日期时保留旧 SQL,兼容历史页面和其他服务调用方。 + * + * @param dto 统计请求 + * @return 日期范围完整时启用新版来源统计 + */ + private boolean isSourceStatistics(HardOrderDto dto) { + return validateStatisticsDateRange(dto == null ? null : dto.getStatisticsDateTimeList()); + } + + /** + * 判断菜品明细请求是否启用来源统计查询。 + * + * @param dto 菜品明细请求 + * @return 日期范围存在且有效时返回 true + */ + private boolean isSourceStatistics(HardCarDto dto) { + return validateStatisticsDateRange(dto == null ? null : dto.getStatisticsDateTimeList()); + } + + /** + * 校验统计日期闭区间;未传时表示兼容旧查询,传入后必须包含有效的开始和结束时间。 + * + * @param range 统计日期时间范围 + * @return 未传返回 false,合法范围返回 true + */ + private boolean validateStatisticsDateRange(LocalDateTime[] range) { + if (range == null) return false; + if (range.length != 2 || range[0] == null || range[1] == null) { + throw new IllegalArgumentException("统计日期范围必须包含开始和结束时间"); + } + if (range[1].isBefore(range[0])) { + throw new IllegalArgumentException("统计结束时间不能早于开始时间"); + } + return true; + } + + /** + * 校验来源编码并补全空来源为全部来源,避免未知编码导致动态 UNION 生成空 SQL。 + * + * @param dto 统计请求 + */ + private void normalizeSaleSources(HardOrderDto dto) { + dto.setSaleSourceCodes(normalizeSaleSources(dto.getSaleSourceCodes())); + } + + /** + * 校验菜品明细来源并将空来源补全为全部来源。 + * + * @param dto 菜品明细请求 + */ + private void normalizeSaleSources(HardCarDto dto) { + dto.setSaleSourceCodes(normalizeSaleSources(dto.getSaleSourceCodes())); + } + + /** + * 统一校验来源编码并去重,保证 Mapper 只接收稳定的来源协议值。 + * + * @param sources 调用方传入的来源编码 + * @return 非空且去重后的来源编码 + */ + private List normalizeSaleSources(List sources) { + if (ObjectUtil.isEmpty(sources)) { + return HardSaleSourceEnum.getCodes(); + } + Set supported = new HashSet<>(HardSaleSourceEnum.getCodes()); + if (sources.stream().anyMatch(source -> !supported.contains(source))) { + throw new IllegalArgumentException("销售来源编码不正确"); + } + return sources.stream().distinct().toList(); + } + + /** + * 为统计主表、展开行或菜品明细批量补充来源中文名称。 + * + * @param rows Mapper 返回的来源统计行;为空时不处理 + */ + private void fillSaleSourceFields(List rows) { + if (ObjectUtil.isEmpty(rows)) return; + for (Object row : rows) { + if (row instanceof HardCanteenProductTotalVo total) fillSaleSourceFields(total); + if (row instanceof HardCarVo car) fillSaleSourceFields(car); + } + } + + /** + * 为菜品统计主表或展开行补充来源名称。 + * + * @param row 单条菜品统计行;来源为空时保留兼容旧查询的空值 + */ + private void fillSaleSourceFields(HardCanteenProductTotalVo row) { + if (row == null || ObjectUtil.isEmpty(row.getSaleSource())) return; + HardSaleSourceEnum source = HardSaleSourceEnum.valueOfCode(row.getSaleSource()); + if (source != null) row.setSaleSourceName(source.getDescription()); + } + + /** + * 为菜品汇总明细补充来源名称。 + * + * @param row 单条菜品明细;来源为空时保留兼容旧查询的空值 + */ + private void fillSaleSourceFields(HardCarVo row) { + if (row == null || ObjectUtil.isEmpty(row.getSaleSource())) return; + HardSaleSourceEnum source = HardSaleSourceEnum.valueOfCode(row.getSaleSource()); + if (source != null) row.setSaleSourceName(source.getDescription()); + } + /** * 为HardCanteenProductTotalVo增加部门信息 */ 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 35896c07d..a70c072a7 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 @@ -43,6 +43,7 @@ import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum; +import com.bsd.pjcloud.hard.api.constant.HardProductSourceEnum; import com.bsd.pjcloud.hard.api.constant.HardThirdMiStatusEnum; import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; @@ -178,6 +179,9 @@ public class HardOrderServiceImpl extends ServiceImpl已支付 if (ObjectUtil.equals(dto.getStatus(), HardUpdateShopStatusDto.ChangeShopStatus.PAY_SUCCESS)) { if (ObjectUtil.notEqual(order.getOrderStatus(), HardOrderStatusEnum.CREATE.getType())) { + if (isShopAlreadyPaid(order.getOrderStatus())) { + return true; + } log.error("|更改商城订单状态|将订单状态从`CREATE(1)`修改到`PAY(5)`错误订单并不是`CREATE(1)`状态"); return false; } @@ -2884,6 +2924,18 @@ public class HardOrderServiceImpl extends ServiceImpl() + .set(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.PAY.getType()) + .set(HardOrderEntity::getVerificationTime, null) + .set(HardOrderEntity::getHardId, null) + .set(HardOrderEntity::getAndroidOrderCode, null) + .eq(HardOrderEntity::getId, order.getId()) + .eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.CANCEL.getType())); + if (updated != 1) { + throw new RuntimeException("订单状态已变化,退回核销状态失败"); + } + + if (ObjectUtil.equals(order.getSysType(), HardTypeEnum.CANTEEN.getType())) { + stopPendingMealVerifiedOutbox(order); + hardBroadcastService.rollbackDoneBroadcast(order); + } + // Wallet 是独立远程事务,放在所有 Hardware 本地写操作之后,减少本地失败后二维码已提前恢复的不一致窗口。 + R codeResult = remoteCodeUseService.rollbackReservationCode(order.getId()); + if (codeResult == null || !codeResult.isOk() || !Boolean.TRUE.equals(codeResult.getData())) { + throw new RuntimeException(codeResult == null + ? "恢复预约核销码无响应" + : StrUtil.blankToDefault(codeResult.getMsg(), "恢复预约核销码失败")); + } + return true; + } + + /** + * 校验订单是否允许从已核销恢复到待核销。退款处理中禁止回退;食堂预约还必须至少保留一份 + * 可履约商品,避免把已经全额退款的历史订单重新暴露给消费机。 + * + * @param order 已持有数据库行锁的订单快照 + */ + private void validateReservationCanRollback(HardOrderEntity order) { + if (ObjectUtil.notEqual(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())) { + throw new RuntimeException("仅线上预订订单支持退回到核销前"); + } + if (!List.of(HardTypeEnum.CANTEEN.getType(), HardTypeEnum.SUPERMARKET.getType()).contains(order.getSysType())) { + throw new RuntimeException("当前业务类型不支持退回到核销前"); + } + if (ObjectUtil.notEqual(order.getOrderStatus(), HardOrderStatusEnum.CANCEL.getType())) { + throw new RuntimeException("仅已核销订单支持退回到核销前"); + } + if (!isReservationRefundStatusFulfillable(order.getRefundStatus())) { + throw new RuntimeException("该预约订单正在退款,不能退回到核销前"); + } + if (ObjectUtil.notEqual(order.getSysType(), HardTypeEnum.CANTEEN.getType())) return; + List cars = hardCarService.getByOrderId(order.getId()); + boolean hasFulfillableProduct = ObjectUtil.isNotEmpty(cars) + && cars.stream().anyMatch(car -> resolveEffectiveProductCount(car) > 0); + if (!hasFulfillableProduct) { + throw new RuntimeException("该预约订单商品已全部退款,不能退回到核销前"); + } + } + + /** + * 停止尚未完成的核销消息投递。已经成功发送的消息属于历史事实无法撤回,继续保留成功状态; + * 再次正确核销时会重新启用未成功记录并刷新业务发生时间。 + * + * @param order 正在退回核销状态的食堂预约订单 + */ + private void stopPendingMealVerifiedOutbox(HardOrderEntity order) { + hardMessageOutboxMapper.update(null, new LambdaUpdateWrapper() + .set(HardMessageOutboxEntity::getStatus, "FAILED") + .set(HardMessageOutboxEntity::getNextRetryTime, null) + .set(HardMessageOutboxEntity::getLastError, MEAL_VERIFICATION_ROLLBACK_OUTBOX_REASON) + .eq(HardMessageOutboxEntity::getEventCode, AppMessageEventEnum.MEAL_VERIFIED.getCode()) + .eq(HardMessageOutboxEntity::getOrderId, order.getId()) + .in(HardMessageOutboxEntity::getStatus, "PENDING", "PROCESSING", "RETRY")); + } + /** * 校验食堂预约订单是否仍可履约,确保旧终端直接调用通用核销接口时不能绕过退款状态与剩余数量。 * @@ -3788,6 +3924,28 @@ public class HardOrderServiceImpl extends ServiceImpl() + .eq(HardMessageOutboxEntity::getEventCode, AppMessageEventEnum.MEAL_VERIFIED.getCode()) + .eq(HardMessageOutboxEntity::getOrderId, order.getId()) + .last("limit 1")); + if (existing != null) { + if (ObjectUtil.equals(existing.getStatus(), "SUCCESS")) return; + int updated = hardMessageOutboxMapper.update(null, new LambdaUpdateWrapper() + .set(HardMessageOutboxEntity::getSourceMchId, order.getMerchantId()) + .set(HardMessageOutboxEntity::getSourceMchName, resolveMessageMerchantName(order.getMerchantId())) + .set(HardMessageOutboxEntity::getMealName, StrUtil.blankToDefault(order.getProductDesc(), "预约用餐")) + .set(HardMessageOutboxEntity::getOccurredTime, verificationTime) + .set(HardMessageOutboxEntity::getStatus, "PENDING") + .set(HardMessageOutboxEntity::getAttemptCount, 0) + .set(HardMessageOutboxEntity::getNextRetryTime, null) + .set(HardMessageOutboxEntity::getProcessedTime, null) + .set(HardMessageOutboxEntity::getLastError, null) + .eq(HardMessageOutboxEntity::getId, existing.getId())); + if (updated != 1) { + throw new IllegalStateException("重新启用用餐核销消息Outbox失败"); + } + return; + } HardMessageOutboxEntity outbox = new HardMessageOutboxEntity(); outbox.setTenantId(order.getTenantId()); outbox.setEventCode(AppMessageEventEnum.MEAL_VERIFIED.getCode()); @@ -3995,28 +4153,29 @@ public class HardOrderServiceImpl extends ServiceImpl refundCarList, - YesNoEnum isReduce, Long afterSaleId) { + private void updateCanteenSpuOrderInventory(HardOrderEntity order, List refundCarList, + YesNoEnum isReduce, Long afterSaleId, boolean validateOnly) { if (ObjectUtil.equals(isReduce, YesNoEnum.NO) && afterSaleId == null) { - throw new RuntimeException("每日菜谱退款库存缺少售后单 ID"); + throw new RuntimeException("食堂菜品退款库存缺少售后单 ID"); } List carList = hardCarService.getByParentCarId(order.getParentCarId()); if (ObjectUtil.isEmpty(carList)) { - throw new RuntimeException("每日菜谱库存操作失败,购物车不存在"); + throw new RuntimeException("食堂菜品库存操作失败,购物车不存在"); } if (ObjectUtil.isNotEmpty(refundCarList)) { carList = filterRefundInventoryCars(carList, refundCarList); } if (ObjectUtil.isEmpty(carList)) { - throw new RuntimeException("每日菜谱库存操作失败,退款商品不存在"); + throw new RuntimeException("食堂菜品库存操作失败,退款商品不存在"); } int factor = ObjectUtil.equals(isReduce, YesNoEnum.YES) ? -1 : 1; @@ -4028,8 +4187,8 @@ public class HardOrderServiceImpl extends ServiceImpl> productResult = remoteCanteenSpuService .getInnerSpuListByIds(new ArrayList<>(stockChanges.keySet())); if (productResult == null || !productResult.isOk() || productResult.getData() == null) { - throw new RuntimeException(productResult == null ? "查询每日菜谱库存无响应" - : StrUtil.blankToDefault(productResult.getMsg(), "查询每日菜谱库存失败")); + throw new RuntimeException(productResult == null ? "查询食堂菜品库存无响应" + : StrUtil.blankToDefault(productResult.getMsg(), "查询食堂菜品库存失败")); } Map productMap = productResult.getData().stream() .filter(Objects::nonNull) @@ -4039,7 +4198,7 @@ public class HardOrderServiceImpl extends ServiceImpl entry : stockChanges.entrySet()) { CanteenSpuEntity product = productMap.get(entry.getKey()); if (product == null || !Objects.equals(product.getMerchantId(), order.getMerchantId())) { - throw new RuntimeException("每日菜谱商品不存在或不属于当前商户"); + throw new RuntimeException("食堂菜品不存在或不属于当前商户"); } int productType = product.getSpuType() == null ? 1 : product.getSpuType(); if (productType != 1 && productType != 2) { @@ -4050,25 +4209,28 @@ public class HardOrderServiceImpl extends ServiceImpl stockChanges, - Map inventorySnapshot, - String inventoryBizId) { + private void registerCanteenSpuInventoryRollback(Map stockChanges, + Map inventorySnapshot, + String inventoryBizId) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { throw new RuntimeException("库存事务状态异常,请稍后重试"); } @@ -4086,9 +4248,9 @@ public class HardOrderServiceImpl extends ServiceImpl stockChanges, - Map inventorySnapshot, - String inventoryBizId, - String operation) { + private void applyCanteenSpuInventoryChange(Map stockChanges, + Map inventorySnapshot, + String inventoryBizId, + String operation) { CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO(); dto.setBizId(inventoryBizId); dto.setOperation(operation); @@ -4118,8 +4280,8 @@ public class HardOrderServiceImpl extends ServiceImpl result = remoteCanteenSpuService.updateSkuStockIdempotent(dto); if (result == null || !result.isOk()) { - throw new RuntimeException(result == null ? "每日菜谱库存服务无响应" - : StrUtil.blankToDefault(result.getMsg(), "每日菜谱库存操作失败")); + throw new RuntimeException(result == null ? "食堂菜品库存服务无响应" + : StrUtil.blankToDefault(result.getMsg(), "食堂菜品库存操作失败")); } } @@ -4136,7 +4298,7 @@ public class HardOrderServiceImpl extends ServiceImpl carList = hardCarService.getByParentCarId(order.getParentCarId()); if (ObjectUtil.isEmpty(carList)) throw new RuntimeException(CarErrorMsg + "错误,购物车不存在"); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml index 9a586fb9a..ed7348bdf 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml @@ -112,6 +112,138 @@ + + + ( + select DATE_FORMAT(o.pay_time, '%Y-%m-%d') as stat_date, + o.pay_time as stat_time, + o.id as order_id, + o.user_id, + o.merchant_id as mch_id, + o.user_dept_id, + o.dept_id, + o.merchant_dept_id, + o.hour_type, + a.product_id, + a.product_name, + a.product_pic, + a.product_price, + a.product_count, + a.total_price, + 'OFFLINE_PURCHASE' as sale_source + from hard_car a + inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0' + inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0' + and o.merchant_type = '1' and o.order_type = '1' + and o.hour_type is not null and o.hour_type not in ('-1') + where a.del_flag = '0' + and o.pay_time is not null + and o.order_status in ('5', '20') + union all + select DATE_FORMAT(o.verification_time, '%Y-%m-%d') as stat_date, + o.verification_time as stat_time, + o.id as order_id, + o.user_id, + o.merchant_id as mch_id, + o.user_dept_id, + o.dept_id, + o.merchant_dept_id, + o.hour_type, + a.product_id, + a.product_name, + a.product_pic, + a.product_price, + a.product_count, + a.total_price, + 'RESERVATION_VERIFIED' as sale_source + from + inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0' + inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0' + and o.merchant_type = '1' and o.order_type = '2' + and (o.appt_id is not null or o.appt_rule_id is not null) + and o.order_status = '20' + and o.verification_time is not null + and o.hour_type is not null and o.hour_type not in ('-1') + ) sale_stats + + + + + + and sale_stats.stat_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]} + + + and sale_stats.sale_source in + + #{v} + + + + and sale_stats.stat_date = #{dto.createTimeStr} + + + and sale_stats.stat_date = #{dto.orderApptUseDateStr} + + + and sale_stats.mch_id = #{dto.merchantId} + + + and sale_stats.mch_id in + + #{v} + + + + and sale_stats.hour_type = #{dto.hourType} + + + + + and sale_stats.dept_id in + + + and sale_stats.dept_id not in + + + + #{v} + + + + + + and sale_stats.user_dept_id in + + + and sale_stats.user_dept_id not in + + + + #{v} + + + + + + + + + and sale_stats.dept_id in + + #{v} + + + + and sale_stats.merchant_dept_id in + + #{v} + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml new file mode 100644 index 000000000..61e63c345 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml @@ -0,0 +1,324 @@ + + + + + + + INSERT IGNORE INTO third_payment_task + (id, tenant_id, task_type, task_key, pay_order_id, fz_order_id, aggregate_session_id, + pay_no, refund_no, fz_no, aggregate_no, status, next_run_time, priority, attempt_count, + create_time, update_time, del_flag) + VALUES + (#{id}, #{tenantId}, #{taskType}, #{taskKey}, #{payOrderId}, #{fzOrderId}, #{aggregateSessionId}, + #{payNo}, #{refundNo}, #{fzNo}, #{aggregateNo}, #{status}, #{nextRunTime}, #{priority}, + #{attemptCount}, #{createTime}, #{updateTime}, #{delFlag}) + + + + UPDATE third_payment_task + SET status = 'RETRY', next_run_time = #{now}, lease_owner = NULL, lease_token = NULL, + lease_expire_time = NULL, last_error_code = 'LEASE_EXPIRED', + last_error_message = 'Worker租约到期,等待Quartz重新分发', update_time = #{now} + WHERE task_type = #{taskType} AND status = 'PROCESSING' AND lease_expire_time <= #{now} + AND del_flag = '0' + + + + + + UPDATE third_payment_task + SET status = 'PROCESSING', lease_owner = #{owner}, lease_token = #{token}, + lease_expire_time = #{expireTime}, attempt_count = attempt_count + 1, update_time = #{now} + WHERE id = #{id} AND status IN ('READY', 'RETRY', 'ALERT') + AND next_run_time <= #{now} AND del_flag = '0' + + + + UPDATE third_payment_task + SET status = 'RETRY', next_run_time = #{nextRunTime}, lease_owner = NULL, lease_token = NULL, + lease_expire_time = NULL, last_error_code = 'WORKER_BUSY', last_error_message = #{message}, + update_time = NOW() + WHERE id = #{id} AND status = 'PROCESSING' AND lease_token = #{token} AND del_flag = '0' + + + + UPDATE third_payment_task + SET status = 'SUCCESS', last_external_status = #{externalStatus}, last_error_code = NULL, + last_error_message = NULL, finished_time = #{now}, lease_owner = NULL, lease_token = NULL, + lease_expire_time = NULL, update_time = #{now} + WHERE id = #{id} AND status = 'PROCESSING' AND lease_token = #{token} AND del_flag = '0' + + + + UPDATE third_payment_task + SET status = #{status}, last_external_status = #{externalStatus}, last_error_code = NULL, + last_error_message = NULL, finished_time = #{now}, update_time = #{now} + WHERE tenant_id = #{tenantId} AND task_key = #{taskKey} + AND status IN ('READY', 'RETRY', 'ALERT') AND del_flag = '0' + + + + UPDATE third_payment_task + SET status = #{status}, next_run_time = #{nextRunTime}, last_external_status = #{externalStatus}, + last_error_code = #{errorCode}, last_error_message = #{errorMessage}, lease_owner = NULL, + lease_token = NULL, lease_expire_time = NULL, update_time = NOW() + WHERE id = #{id} AND status = 'PROCESSING' AND lease_token = #{token} AND del_flag = '0' + + + + UPDATE third_payment_task + SET status = 'FAILED', last_error_code = #{errorCode}, last_error_message = #{errorMessage}, + finished_time = #{now}, lease_owner = NULL, lease_token = NULL, lease_expire_time = NULL, + update_time = #{now} + WHERE id = #{id} AND status = 'PROCESSING' AND lease_token = #{token} AND del_flag = '0' + + + + + + UPDATE third_payment_task + SET status = 'RETRY', next_run_time = #{nextRunTime}, finished_time = NULL, + lease_owner = NULL, lease_token = NULL, lease_expire_time = NULL, + last_error_code = NULL, last_error_message = NULL, update_time = #{nextRunTime} + WHERE id = #{id} AND status IN ('FAILED', 'ALERT', 'CANCELLED') AND del_flag = '0' + + 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 index d846fc439..0f1d35898 100644 --- 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 @@ -2,9 +2,15 @@ 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.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService; import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusService; +import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatFzService; +import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; import com.bsd.pjcloud.thirdparty.service.ThirdPayPollingCoordinator; import org.junit.jupiter.api.AfterEach; @@ -14,6 +20,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; import java.util.List; @@ -41,6 +48,24 @@ class ThirdAppPlusPayJobTest { @Mock private ThirdAppPlusService thirdAppPlusService; + /** + * 分账订单服务 Mock,用于验证最终分账标记的重启恢复。 + */ + @Mock + private ThirdFzOrderService thirdFzOrderService; + + /** + * 微信分账服务 Mock,补齐被测任务的构造依赖。 + */ + @Mock + private ThirdAppPlusWechatFzService thirdAppPlusWechatFzService; + + /** + * 支付宝直付通服务 Mock,补齐被测任务的构造依赖。 + */ + @Mock + private ThirdAppPlusAliPayService thirdAppPlusAliPayService; + /** * 自适应轮询协调器 Mock,用于控制本轮是否允许访问数据库。 */ @@ -112,7 +137,64 @@ class ThirdAppPlusPayJobTest { job.closeHistoricalOrderCodeOrders(); - verify(thirdPayOrderService).update(any(Wrapper.class)); + verify(thirdPayOrderService).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); verifyNoInteractions(thirdAppPlusService); } + + /** + * 验证 App 待支付订单不受一小时创建窗口限制,并在仍未支付时更新时间轮转到批次队尾。 + */ + @Test + void handleAppPaymentOrdersShouldRecoverHistoricalOrderAndRotatePendingRecord() { + ThirdPayOrderEntity order = new ThirdPayOrderEntity(); + order.setId(102L); + order.setTenantId(66L); + order.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType()); + order.setCreateTime(LocalDateTime.now().minusHours(2)); + order.setUpdateTime(LocalDateTime.now().minusHours(2)); + when(pollingCoordinator.tryStartAppPaymentPolling()).thenReturn(true); + when(thirdPayOrderService.list(any(Wrapper.class))).thenReturn(List.of(order)); + when(thirdPayOrderService.getById(order.getId())).thenReturn(order); + + job.handleAppPaymentOrders(); + + verify(thirdAppPlusService).queryPayResult(order); + org.mockito.ArgumentCaptor updateCaptor = + org.mockito.ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertNotNull(updateCaptor.getValue().getUpdateTime()); + verify(pollingCoordinator).finishAppPaymentPolling(true); + Assertions.assertNull(TenantContextHolder.getTenantId()); + } + + /** + * 验证服务在分账终态落库后退出,下一轮仍能把最终抽佣金额和成功标记补写到支付订单。 + */ + @Test + void reconcileSuccessfulFzOrdersShouldRecoverInterruptedFinalMarker() { + ThirdPayOrderEntity order = new ThirdPayOrderEntity(); + order.setId(101L); + order.setTenantId(66L); + order.setPayNo("pay-101"); + order.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); + order.setEnableFz(YesNoEnum.YES.getCode()); + order.setIsFzSuccess(YesNoEnum.NO.getCode()); + ThirdFzOrderEntity fzOrder = new ThirdFzOrderEntity(); + fzOrder.setId(order.getId()); + fzOrder.setPayNo(order.getPayNo()); + fzOrder.setPrice(123); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_SUCCESS.getType()); + when(thirdPayOrderService.list(any(Wrapper.class))).thenReturn(List.of(order)); + when(thirdPayOrderService.getById(order.getId())).thenReturn(order); + when(thirdFzOrderService.getByPayNo(order.getPayNo())).thenReturn(fzOrder); + + ReflectionTestUtils.invokeMethod(job, "reconcileSuccessfulFzOrders"); + + org.mockito.ArgumentCaptor updateCaptor = + org.mockito.ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(123, updateCaptor.getValue().getFzPrice()); + Assertions.assertEquals(YesNoEnum.YES.getCode(), updateCaptor.getValue().getIsFzSuccess()); + Assertions.assertNull(TenantContextHolder.getTenantId()); + } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapperSqlContractTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapperSqlContractTest.java new file mode 100644 index 000000000..2d1304478 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapperSqlContractTest.java @@ -0,0 +1,85 @@ +package com.bsd.pjcloud.thirdparty.mapper.pay; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +/** + * 支付任务 Mapper 原子条件契约测试,防止维护 SQL 时移除跨实例安全所需的状态或租约约束。 + */ +class ThirdPaymentTaskMapperSqlContractTest { + + /** MyBatis 支付任务映射资源路径。 */ + private static final String MAPPER_RESOURCE = "mapper/ThirdPaymentTaskMapper.xml"; + + /** 测试启动时读取的 Mapper XML。 */ + private static String mapperXml; + + /** + * 从测试类路径读取本轮实际参与编译的 Mapper XML。 + * + * @throws IOException 资源读取失败时抛出 + */ + @BeforeAll + static void loadMapperXml() throws IOException { + try (InputStream input = ThirdPaymentTaskMapperSqlContractTest.class.getClassLoader() + .getResourceAsStream(MAPPER_RESOURCE)) { + Assertions.assertNotNull(input, "支付任务 Mapper XML 必须进入运行时类路径"); + mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } + + /** + * 验证领取动作必须同时受可领取原状态和到期时间约束,多个 thirdparty 实例只能有一个 CAS 成功。 + */ + @Test + void claimShouldKeepOriginalStatusAndDueTimeConditions() { + String sql = statement("update", "claim"); + Assertions.assertAll( + () -> Assertions.assertTrue(sql.contains("status IN ('READY', 'RETRY', 'ALERT')")), + () -> Assertions.assertTrue(sql.contains("next_run_time <= #{now}"))); + } + + /** + * 验证完成、重试、失败和队列释放都校验当前租约 Token,过期 Worker 不能覆盖新 Worker。 + */ + @Test + void resultUpdatesShouldRequireCurrentLeaseToken() { + for (String id : new String[]{"release", "complete", "retry", "fail"}) { + String sql = statement("update", id); + Assertions.assertTrue(sql.contains("status = 'PROCESSING'"), id + " 必须限制原状态"); + Assertions.assertTrue(sql.contains("lease_token = #{token}"), id + " 必须限制当前租约令牌"); + } + } + + /** + * 验证仅恢复已过期的处理中租约,未到期 Worker 不会被 Quartz 下一轮提前抢占。 + */ + @Test + void recoveryShouldOnlyReleaseExpiredProcessingLeases() { + String sql = statement("update", "recoverExpiredLeases"); + Assertions.assertAll( + () -> Assertions.assertTrue(sql.contains("status = 'PROCESSING'")), + () -> Assertions.assertTrue(sql.contains("lease_expire_time <= #{now}")), + () -> Assertions.assertTrue(sql.contains("status = 'RETRY'"))); + } + + /** + * 提取指定 MyBatis 语句片段。 + * + * @param element XML 元素名,例如 update + * @param id MyBatis 语句 ID + * @return 对应语句 XML 片段 + */ + private static String statement(String element, String id) { + String startToken = "<" + element + " id=\"" + id + "\">"; + int start = mapperXml.indexOf(startToken); + int end = mapperXml.indexOf("", start); + Assertions.assertTrue(start >= 0 && end > start, "缺少 MyBatis 语句:" + id); + return mapperXml.substring(start, end); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentOrderReservationServiceTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentOrderReservationServiceTest.java new file mode 100644 index 000000000..17b1898db --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentOrderReservationServiceTest.java @@ -0,0 +1,70 @@ +package com.bsd.pjcloud.thirdparty.service; + +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskMapper; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.when; + +/** + * 支付订单预留事务测试,验证调用渠道前订单与首任务在同一事务方法中按顺序持久化。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdPaymentOrderReservationServiceTest { + + /** 支付订单服务 Mock。 */ + @Mock + private ThirdPayOrderService payOrderService; + + /** 支付任务写入器 Mock。 */ + @Mock + private ThirdPaymentTaskEnqueuer taskEnqueuer; + + /** 支付任务 Mapper Mock,用于补齐渠道同步响应收口依赖。 */ + @Mock + private ThirdPaymentTaskMapper taskMapper; + + /** 被测支付订单预留服务。 */ + @InjectMocks + private ThirdPaymentOrderReservationService reservationService; + + /** + * 验证订单保存成功后才写入 PAY_CREATE,并确认事务在外部渠道调用前以独立事务提交。 + * + * @throws Exception 反射读取方法声明失败时抛出 + */ + @Test + void reserveAppPayShouldPersistOrderAndCreateTaskInRequiresNewTransaction() throws Exception { + ThirdPayOrderEntity order = new ThirdPayOrderEntity(); + order.setId(101L); + order.setTenantId(9L); + order.setPayNo("pay-1001"); + when(payOrderService.save(order)).thenReturn(true); + + reservationService.reserveAppPay(order); + + InOrder inOrder = inOrder(payOrderService, taskEnqueuer); + inOrder.verify(payOrderService).save(order); + inOrder.verify(taskEnqueuer).enqueue(9L, ThirdPaymentTaskTypeEnum.PAY_CREATE, "PAY_CREATE:pay-1001", + 101L, "pay-1001", null, null, null); + Method method = ThirdPaymentOrderReservationService.class.getDeclaredMethod("reserveAppPay", ThirdPayOrderEntity.class); + Transactional transactional = method.getAnnotation(Transactional.class); + Assertions.assertAll( + () -> Assertions.assertNotNull(transactional), + () -> Assertions.assertEquals(Propagation.REQUIRES_NEW, transactional.propagation()), + () -> Assertions.assertTrue(Arrays.asList(transactional.rollbackFor()).contains(Exception.class))); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuerTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuerTest.java new file mode 100644 index 000000000..ca01a0679 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuerTest.java @@ -0,0 +1,75 @@ +package com.bsd.pjcloud.thirdparty.service; + +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPaymentTaskEntity; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskMapper; +import org.junit.jupiter.api.Assertions; +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.util.List; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * 支付任务实时写入器测试,验证数据库唯一键依赖的任务键稳定性和非自增主键初始化。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdPaymentTaskEnqueuerTest { + + /** 支付任务 Mapper Mock,用于捕获实际准备写入数据库的任务事实。 */ + @Mock + private ThirdPaymentTaskMapper taskMapper; + + /** 被测任务写入器。 */ + @InjectMocks + private ThirdPaymentTaskEnqueuer enqueuer; + + /** + * 验证同一支付号重复入队始终生成相同任务键,同时每次插入都具备可用的分布式主键。 + */ + @Test + void enqueuePayQueryShouldUseStableTaskKeyAndAssignedId() { + enqueuer.enqueuePayQuery(9L, 101L, "pay-1001"); + enqueuer.enqueuePayQuery(9L, 101L, "pay-1001"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ThirdPaymentTaskEntity.class); + verify(taskMapper, times(2)).insertIgnore(captor.capture()); + List tasks = captor.getAllValues(); + Assertions.assertAll( + () -> Assertions.assertNotNull(tasks.get(0).getId()), + () -> Assertions.assertNotNull(tasks.get(1).getId()), + () -> Assertions.assertNotEquals(tasks.get(0).getId(), tasks.get(1).getId()), + () -> Assertions.assertEquals("PAY_QUERY:pay-1001", tasks.get(0).getTaskKey()), + () -> Assertions.assertEquals(tasks.get(0).getTaskKey(), tasks.get(1).getTaskKey()), + () -> Assertions.assertEquals(ThirdPaymentTaskTypeEnum.PAY_QUERY.getCode(), tasks.get(0).getTaskType()), + () -> Assertions.assertEquals("READY", tasks.get(0).getStatus()), + () -> Assertions.assertEquals(0, tasks.get(0).getAttemptCount()), + () -> Assertions.assertEquals("0", tasks.get(0).getDelFlag()), + () -> Assertions.assertNotNull(tasks.get(0).getNextRunTime())); + } + + /** + * 验证退分账查询任务键同时绑定原分账号和稳定退分账号,重试不会生成新的渠道单据。 + */ + @Test + void enqueueFzRefundQueryShouldKeepOriginalBusinessNumbers() { + enqueuer.enqueueFzRefundQuery(9L, 101L, 201L, "pay-1001", "fz-pay-1001", "fz-refund-pay-1001"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ThirdPaymentTaskEntity.class); + verify(taskMapper).insertIgnore(captor.capture()); + ThirdPaymentTaskEntity task = captor.getValue(); + Assertions.assertAll( + () -> Assertions.assertEquals("FZ_REFUND_QUERY:fz-pay-1001:fz-refund-pay-1001", task.getTaskKey()), + () -> Assertions.assertEquals(ThirdPaymentTaskTypeEnum.FZ_REFUND_QUERY.getCode(), task.getTaskType()), + () -> Assertions.assertEquals("pay-1001", task.getPayNo()), + () -> Assertions.assertEquals("fz-pay-1001", task.getFzNo()), + () -> Assertions.assertEquals(201L, task.getFzOrderId()), + () -> Assertions.assertEquals(110, task.getPriority())); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java index 0cc2edd02..21cda0d00 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java @@ -4,21 +4,44 @@ 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.AlipayTradeAppPayModel; +import com.alipay.api.domain.AlipayTradeOrderSettleModel; +import com.alipay.api.domain.AlipayTradeOrderSettleQueryModel; import com.alipay.api.domain.AlipayTradePrecreateModel; +import com.alipay.api.domain.AlipayTradeRefundModel; +import com.alipay.api.domain.AlipayTradeSettleConfirmModel; +import com.alipay.api.domain.OpenApiRoyaltyDetailInfoPojo; +import com.alipay.api.domain.RoyaltyDetail; +import com.alipay.api.request.AlipayTradeAppPayRequest; +import com.alipay.api.request.AlipayTradeFastpayRefundQueryRequest; +import com.alipay.api.request.AlipayTradeOrderSettleRequest; +import com.alipay.api.request.AlipayTradeOrderSettleQueryRequest; import com.alipay.api.request.AlipayTradePrecreateRequest; +import com.alipay.api.request.AlipayTradeRefundRequest; +import com.alipay.api.request.AlipayTradeSettleConfirmRequest; +import com.alipay.api.response.AlipayTradeAppPayResponse; +import com.alipay.api.response.AlipayTradeFastpayRefundQueryResponse; +import com.alipay.api.response.AlipayTradeOrderSettleResponse; +import com.alipay.api.response.AlipayTradeOrderSettleQueryResponse; import com.alipay.api.response.AlipayTradePrecreateResponse; +import com.alipay.api.response.AlipayTradeRefundResponse; +import com.alipay.api.response.AlipayTradeSettleConfirmResponse; 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.fz.ThirdFzOrderStatusEnum; 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.ThirdMiniWechatRefundDto; 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.ThirdFzOrderEntity; 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.ThirdFzOrderService; 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; @@ -28,19 +51,26 @@ 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.InOrder; 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.List; import java.util.Map; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +/** + * 支付宝订单码、App 直付通、结算抽佣及已分账退款的关键协议测试。 + */ @ExtendWith(MockitoExtension.class) class ThirdAppPlusAliPayServiceImplTest { @@ -50,6 +80,12 @@ class ThirdAppPlusAliPayServiceImplTest { @Mock private AlipayClient alipayClient; + /** + * 分账订单服务 Mock,用于保存支付宝确认结算、平台抽佣和退分账快照。 + */ + @Mock + private ThirdFzOrderService thirdFzOrderService; + /** * 聚合会话服务 Mock,用于校验支付宝 JSAPI 回调必须存在严格 pay_no 绑定。 */ @@ -73,13 +109,19 @@ class ThirdAppPlusAliPayServiceImplTest { aliPayConfig.setAlipayPublicKey("test-public-key"); aliPayConfig.setBaseCallbackUrl("https://pay.example.com"); aliPayConfig.setPayCallbackUrl("/third-app-pay/app/pay-callback"); + aliPayConfig.setRefundCallbackUrl("/third-app-pay/app/refund-callback"); + aliPayConfig.setRoyaltyTransIn("2088000000000001"); + aliPayConfig.setRoyaltyTransInType("userId"); + aliPayConfig.setRoyaltyTransInName("资安信平台"); appPlusPayConfig.setAliPayConfig(aliPayConfig); MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting(); aliPaySetting.setAppAuthToken("merchant-auth-token"); + aliPaySetting.setSubMchid("smid-1001"); paySetting = new MchPaySettingVo(); paySetting.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType()); paySetting.setEnable(YesNoEnum.YES.getCode()); + paySetting.setFzRatio(10D); paySetting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); payDto = new ThirdAppPlusPayDto(); @@ -91,6 +133,246 @@ class ThirdAppPlusAliPayServiceImplTest { payDto.setMchId(88L); service = new TestableThirdAppPlusAliPayService(appPlusPayConfig, thirdPayOrderService, alipayClient); ReflectionTestUtils.setField(service, "aggregatePaySessionService", aggregatePaySessionService); + ReflectionTestUtils.setField(service, "thirdFzOrderService", thirdFzOrderService); + } + + /** + * 验证 App 支付把可信 smid、金额和十五分钟有效期写入直付通请求,创建阶段不写支付时间。 + */ + @Test + void appPayShouldCreateDirectPayOrderWithoutMarkingPaymentSuccess() throws Exception { + AlipayTradeAppPayResponse response = new AlipayTradeAppPayResponse(); + response.setBody("signed-alipay-order"); + when(alipayClient.sdkExecute(any(AlipayTradeAppPayRequest.class))).thenReturn(response); + LocalDateTime beforeCreate = LocalDateTime.now(); + + String result = service.appPay(payDto, paySetting); + + Assertions.assertEquals("signed-alipay-order", result); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeAppPayRequest.class); + verify(alipayClient).sdkExecute(requestCaptor.capture()); + AlipayTradeAppPayRequest request = requestCaptor.getValue(); + AlipayTradeAppPayModel model = (AlipayTradeAppPayModel) request.getBizModel(); + Assertions.assertEquals("smid-1001", model.getSubMerchant().getMerchantId()); + Assertions.assertEquals("12.34", model.getTotalAmount()); + LocalDateTime timeExpire = LocalDateTimeUtil.parse(model.getTimeExpire(), DatePattern.NORM_DATETIME_PATTERN); + Assertions.assertFalse(timeExpire.isBefore(beforeCreate.plusMinutes(14).plusSeconds(55))); + Assertions.assertFalse(timeExpire.isAfter(LocalDateTime.now().plusMinutes(15).plusSeconds(5))); + Assertions.assertEquals("merchant-auth-token", request.getTextParams().get("app_auth_token")); + + 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(88L, savedOrder.getMchId()); + Assertions.assertEquals(YesNoEnum.YES.getCode(), savedOrder.getEnableFz()); + Assertions.assertNull(savedOrder.getPayTime()); + } + + /** + * 验证未配置授权令牌时请求不产生空的 app_auth_token 公共参数。 + */ + @Test + void appPayShouldOmitBlankAppAuthToken() throws Exception { + MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean( + paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + aliPaySetting.setAppAuthToken(null); + paySetting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); + AlipayTradeAppPayResponse response = new AlipayTradeAppPayResponse(); + response.setBody("signed-alipay-order"); + when(alipayClient.sdkExecute(any(AlipayTradeAppPayRequest.class))).thenReturn(response); + + service.appPay(payDto, paySetting); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeAppPayRequest.class); + verify(alipayClient).sdkExecute(requestCaptor.capture()); + Assertions.assertFalse(requestCaptor.getValue().getTextParams().containsKey("app_auth_token")); + } + + /** + * 验证支付宝明确拒绝商户授权时结束直付通分账,不会降级调用普通平台抽佣接口。 + */ + @Test + void directPayShouldFailWithoutRoyaltyFallbackWhenAuthorizationIsRejected() throws Exception { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + ThirdFzOrderEntity fzOrder = pendingFzOrder(payOrder); + AlipayTradeSettleConfirmResponse rejected = new AlipayTradeSettleConfirmResponse(); + rejected.setCode("40004"); + rejected.setSubCode("isv.insufficient-isv-permissions"); + when(alipayClient.execute(any(AlipayTradeSettleConfirmRequest.class))).thenReturn(rejected); + when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.confirmDirectPaySettlementStage(fzOrder, payOrder); + + verify(alipayClient).execute(any(AlipayTradeSettleConfirmRequest.class)); + verify(alipayClient, never()).execute(any(AlipayTradeOrderSettleRequest.class)); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_FAILED.getType(), fzOrder.getStatus()); + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + /** + * 验证确认结算严格先于平台抽佣,并且只有两阶段均成功才写最终分账成功。 + */ + @Test + void directPayShouldConfirmSettlementBeforeRoyalty() throws Exception { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + ThirdFzOrderEntity fzOrder = pendingFzOrder(payOrder); + AlipayTradeSettleConfirmResponse confirmResponse = successSettleConfirmResponse(); + AlipayTradeOrderSettleResponse royaltyResponse = successRoyaltyResponse(); + when(alipayClient.execute(any(AlipayTradeSettleConfirmRequest.class))).thenReturn(confirmResponse); + when(alipayClient.execute(any(AlipayTradeOrderSettleRequest.class))).thenReturn(royaltyResponse); + when(alipayClient.execute(any(AlipayTradeOrderSettleQueryRequest.class))).thenReturn(successRoyaltyQueryResponse()); + when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + Assertions.assertEquals("SUCCESS", service.confirmDirectPaySettlementStage(fzOrder, payOrder)); + Assertions.assertEquals("SUBMITTED", service.submitDirectPayRoyaltyStage(fzOrder, payOrder)); + Assertions.assertEquals("SUCCESS", service.queryDirectPayRoyaltyStage(fzOrder, payOrder)); + + InOrder inOrder = inOrder(alipayClient); + inOrder.verify(alipayClient).execute(any(AlipayTradeSettleConfirmRequest.class)); + inOrder.verify(alipayClient).execute(any(AlipayTradeOrderSettleRequest.class)); + inOrder.verify(alipayClient).execute(any(AlipayTradeOrderSettleQueryRequest.class)); + ArgumentCaptor queryCaptor = + ArgumentCaptor.forClass(AlipayTradeOrderSettleQueryRequest.class); + verify(alipayClient).execute(queryCaptor.capture()); + AlipayTradeOrderSettleQueryModel queryModel = + (AlipayTradeOrderSettleQueryModel) queryCaptor.getValue().getBizModel(); + Assertions.assertEquals("royalty_" + payOrder.getPayNo(), queryModel.getOutRequestNo()); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_SUCCESS.getType(), fzOrder.getStatus()); + Assertions.assertEquals("SUCCESS", JSONUtil.parseObj(fzOrder.getCreateRes()) + .getJSONObject("settleConfirm").getStr("status")); + Assertions.assertEquals("SUCCESS", JSONUtil.parseObj(fzOrder.getCreateRes()) + .getJSONObject("royalty").getStr("status")); + verify(thirdPayOrderService).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + /** + * 验证确认结算遇到系统不确定结果后使用相同 out_request_no 重试。 + */ + @Test + void directPayRetryShouldKeepStableSettleRequestNo() throws Exception { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + ThirdFzOrderEntity fzOrder = pendingFzOrder(payOrder); + AlipayTradeSettleConfirmResponse processing = new AlipayTradeSettleConfirmResponse(); + processing.setCode("40004"); + processing.setSubCode("ACQ.SYSTEM_ERROR"); + when(alipayClient.execute(any(AlipayTradeSettleConfirmRequest.class))) + .thenReturn(processing, successSettleConfirmResponse()); + when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.confirmDirectPaySettlementStage(fzOrder, payOrder); + service.confirmDirectPaySettlementStage(fzOrder, payOrder); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(AlipayTradeSettleConfirmRequest.class); + verify(alipayClient, times(2)).execute(requestCaptor.capture()); + List requests = requestCaptor.getAllValues(); + String firstRequestNo = ((AlipayTradeSettleConfirmModel) requests.get(0).getBizModel()).getOutRequestNo(); + String secondRequestNo = ((AlipayTradeSettleConfirmModel) requests.get(1).getBizModel()).getOutRequestNo(); + Assertions.assertEquals("settle_" + payOrder.getPayNo(), firstRequestNo); + Assertions.assertEquals(firstRequestNo, secondRequestNo); + } + + /** + * 验证旧实例返回的处理中快照只能在数据库仍为待处理时写入,不能整行覆盖并发成功终态。 + */ + @Test + void staleDirectPayPollingShouldNotOverwriteSuccessfulFzOrder() throws Exception { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + ThirdFzOrderEntity staleFzOrder = pendingFzOrder(payOrder); + AlipayTradeSettleConfirmResponse processing = new AlipayTradeSettleConfirmResponse(); + processing.setCode("40004"); + processing.setSubCode("ACQ.SYSTEM_ERROR"); + when(alipayClient.execute(any(AlipayTradeSettleConfirmRequest.class))).thenReturn(processing); + // false 模拟另一实例已经把同一记录推进为 FZ_SUCCESS,当前旧快照的原状态条件不再成立。 + when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(false); + + service.confirmDirectPaySettlementStage(staleFzOrder, payOrder); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdFzOrderEntity.class); + verify(thirdFzOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertNull(updateCaptor.getValue().getStatus()); + verify(thirdFzOrderService, never()).updateById(any(ThirdFzOrderEntity.class)); + verify(alipayClient, never()).execute(any(AlipayTradeOrderSettleRequest.class)); + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + /** + * 验证已完成平台抽佣的支付宝订单退款携带按退款金额折算的 refund_royalty_parameters。 + */ + @Test + void refundShouldIncludeRoyaltyParametersAfterProfitSharing() throws Exception { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + ThirdFzOrderEntity fzOrder = pendingFzOrder(payOrder); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_SUCCESS.getType()); + fzOrder.setPrice(123); + when(thirdFzOrderService.getByPayNo(payOrder.getPayNo())).thenReturn(fzOrder); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + AlipayTradeRefundResponse response = new AlipayTradeRefundResponse(); + response.setCode("10000"); + response.setBody("{\"code\":\"10000\"}"); + when(alipayClient.execute(any(AlipayTradeRefundRequest.class))).thenReturn(response); + ThirdMiniWechatRefundDto refundDto = new ThirdMiniWechatRefundDto(); + refundDto.setAfterSaleId(77L); + refundDto.setRefundPrice(500); + + boolean result = service.refund(payOrder, refundDto); + + Assertions.assertFalse(result); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeRefundRequest.class); + verify(alipayClient).execute(requestCaptor.capture()); + AlipayTradeRefundModel model = (AlipayTradeRefundModel) requestCaptor.getValue().getBizModel(); + Assertions.assertEquals("refund_77", model.getOutRequestNo()); + Assertions.assertEquals("5.00", model.getRefundAmount()); + Assertions.assertEquals(1, model.getRefundRoyaltyParameters().size()); + OpenApiRoyaltyDetailInfoPojo royalty = model.getRefundRoyaltyParameters().get(0); + Assertions.assertEquals("2088000000000001", royalty.getTransOut()); + Assertions.assertEquals("userId", royalty.getTransOutType()); + Assertions.assertEquals("0.49", royalty.getAmount()); + Assertions.assertTrue(fzOrder.getFzRefundReq().contains("2088000000000001")); + } + + /** + * 验证退款查询找不到原请求时使用已保存的相同退款号和参数重提,受理后继续等待查询终态。 + */ + @Test + void queryRefundShouldRetryMissingRequestWithOriginalIdempotencyKey() throws Exception { + ThirdPayOrderEntity payOrder = refundingOrderWithRequest("refund_77"); + AlipayTradeFastpayRefundQueryResponse missing = new AlipayTradeFastpayRefundQueryResponse(); + missing.setCode("40004"); + missing.setSubCode("ACQ.REFUND_NOT_EXIST"); + when(alipayClient.execute(any(AlipayTradeFastpayRefundQueryRequest.class))).thenReturn(missing); + AlipayTradeRefundResponse accepted = new AlipayTradeRefundResponse(); + accepted.setCode("10000"); + accepted.setBody("{\"code\":\"10000\"}"); + when(alipayClient.execute(any(AlipayTradeRefundRequest.class))).thenReturn(accepted); + + AlipayTradeFastpayRefundQueryResponse result = service.queryRefund(payOrder); + + Assertions.assertNull(result); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeRefundRequest.class); + verify(alipayClient).execute(requestCaptor.capture()); + AlipayTradeRefundModel retryModel = (AlipayTradeRefundModel) requestCaptor.getValue().getBizModel(); + Assertions.assertEquals(payOrder.getPayNo(), retryModel.getOutTradeNo()); + Assertions.assertEquals(payOrder.getRefundNo(), retryModel.getOutRequestNo()); + Assertions.assertEquals("5.00", retryModel.getRefundAmount()); + } + + /** + * 验证退款查询系统错误保持可重试,且不会在未确认请求缺失时重复提交退款。 + */ + @Test + void queryRefundShouldKeepSystemErrorRetryableWithoutResubmitting() throws Exception { + ThirdPayOrderEntity payOrder = refundingOrderWithRequest("refund_78"); + AlipayTradeFastpayRefundQueryResponse processing = new AlipayTradeFastpayRefundQueryResponse(); + processing.setCode("40004"); + processing.setSubCode("ACQ.SYSTEM_ERROR"); + when(alipayClient.execute(any(AlipayTradeFastpayRefundQueryRequest.class))).thenReturn(processing); + + AlipayTradeFastpayRefundQueryResponse result = service.queryRefund(payOrder); + + Assertions.assertSame(processing, result); + verify(alipayClient, never()).execute(any(AlipayTradeRefundRequest.class)); } @Test @@ -279,6 +561,96 @@ class ThirdAppPlusAliPayServiceImplTest { return order; } + /** + * 构造已确认支付成功且包含商户配置快照的直付通支付订单。 + * + * @return 可进入确认结算或退款流程的支付订单 + */ + private ThirdPayOrderEntity paidDirectPayOrder() { + ThirdPayOrderEntity order = unpaidOrder(); + order.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); + order.setThirdPayNo("202600000001"); + order.setPayTime(LocalDateTime.now().minusMinutes(1)); + order.setEnableFz(YesNoEnum.YES.getCode()); + order.setMchPaySettingJson(JSONUtil.toJsonStr(paySetting)); + return order; + } + + /** + * 构造包含稳定退款号和原始退款请求快照的退款中订单。 + * + * @param refundNo 退款幂等请求号 + * @return 可执行支付宝退款查询与安全重提的支付订单 + */ + private ThirdPayOrderEntity refundingOrderWithRequest(String refundNo) { + ThirdPayOrderEntity order = paidDirectPayOrder(); + order.setStatus(ThirdPayOrderStatusEnum.REFUNDING.getType()); + order.setRefundNo(refundNo); + AlipayTradeRefundModel model = new AlipayTradeRefundModel(); + model.setOutTradeNo(order.getPayNo()); + model.setOutRequestNo(refundNo); + model.setRefundAmount("5.00"); + model.setRefundReason("测试退款"); + order.setRefundRep(JSONUtil.toJsonStr(model)); + return order; + } + + /** + * 构造使用稳定分账号的待处理分账记录。 + * + * @param payOrder 对应支付订单 + * @return 待确认结算的分账记录 + */ + private ThirdFzOrderEntity pendingFzOrder(ThirdPayOrderEntity payOrder) { + ThirdFzOrderEntity fzOrder = new ThirdFzOrderEntity(); + fzOrder.setId(payOrder.getId()); + fzOrder.setPayNo(payOrder.getPayNo()); + fzOrder.setFzNo("fz_" + payOrder.getPayNo()); + fzOrder.setPrice(123); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_UNPAID.getType()); + return fzOrder; + } + + /** + * 构造支付宝确认结算成功响应。 + * + * @return SDK 成功响应 + */ + private AlipayTradeSettleConfirmResponse successSettleConfirmResponse() { + AlipayTradeSettleConfirmResponse response = new AlipayTradeSettleConfirmResponse(); + response.setCode("10000"); + response.setMsg("Success"); + return response; + } + + /** + * 构造支付宝平台抽佣成功响应。 + * + * @return SDK 成功响应 + */ + private AlipayTradeOrderSettleResponse successRoyaltyResponse() { + AlipayTradeOrderSettleResponse response = new AlipayTradeOrderSettleResponse(); + response.setCode("10000"); + response.setMsg("Success"); + return response; + } + + /** + * 构造支付宝明确返回抽佣明细成功的查询响应。 + * @return SDK 抽佣查询成功响应 + */ + private AlipayTradeOrderSettleQueryResponse successRoyaltyQueryResponse() { + RoyaltyDetail detail = new RoyaltyDetail(); + detail.setState("SUCCESS"); + detail.setAmount("1.23"); + AlipayTradeOrderSettleQueryResponse response = new AlipayTradeOrderSettleQueryResponse(); + response.setCode("10000"); + response.setMsg("Success"); + response.setOutRequestNo("royalty_" + payDto.getOutTradeNo()); + response.setRoyaltyDetailList(List.of(detail)); + return response; + } + private Map successCallbackParams() { Map params = new HashMap<>(); params.put("app_id", appPlusPayConfig.getAliPayConfig().getAppId()); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java index 53db7cc0a..e7ec734de 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java @@ -11,17 +11,20 @@ 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.hard.api.entity.order.HardOrderEntity; 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.ThirdMiniRemotePayDto; 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.thirdparty.vo.appplus.pay.wechat.ThirdMiniWechatRefundQueryVo; 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; @@ -332,6 +335,7 @@ class ThirdAppPlusServiceImplOrderCodePayTest { .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()); + paidOrder.setIsBizCallback(YesNoEnum.YES.getCode()); when(thirdPayOrderService.getById(unpaidOrder.getId())).thenReturn(paidOrder); service.queryPayResult(unpaidOrder); @@ -465,6 +469,158 @@ class ThirdAppPlusServiceImplOrderCodePayTest { Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_CLOSED.getType(), updateCaptor.getValue().getStatus()); } + /** + * 验证普通微信 App 支付到期后调用 App 关单接口,渠道确认成功才推进本地关闭状态。 + */ + @Test + void queryPayResultShouldCloseExpiredWechatAppOrderAfterChannelConfirmation() { + ThirdPayOrderEntity expiredOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().minusSeconds(1)); + expiredOrder.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType()); + expiredOrder.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY.getType()); + com.wechat.pay.java.service.partnerpayments.app.model.Transaction transaction = + new com.wechat.pay.java.service.partnerpayments.app.model.Transaction(); + transaction.setTradeState(com.wechat.pay.java.service.partnerpayments.app.model.Transaction.TradeStateEnum.NOTPAY); + when(thirdMiniAppWechatService.query(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())) + .thenReturn(transaction); + when(thirdMiniAppWechatService.close(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())) + .thenReturn(true); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.queryPayResult(expiredOrder); + + verify(thirdMiniAppWechatService).close(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson()); + verify(thirdAppPlusWechatNativePayService, never()).close(any(), any()); + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_CLOSED.getType(), updateCaptor.getValue().getStatus()); + } + + /** + * 验证微信 App 关单网络结果未知时保持未支付,等待原任务号下一轮重试。 + */ + @Test + void queryPayResultShouldKeepExpiredWechatAppOrderUnpaidWhenCloseIsUnknown() { + ThirdPayOrderEntity expiredOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().minusSeconds(1)); + expiredOrder.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType()); + expiredOrder.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY.getType()); + com.wechat.pay.java.service.partnerpayments.app.model.Transaction transaction = + new com.wechat.pay.java.service.partnerpayments.app.model.Transaction(); + transaction.setTradeState(com.wechat.pay.java.service.partnerpayments.app.model.Transaction.TradeStateEnum.NOTPAY); + when(thirdMiniAppWechatService.query(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())) + .thenReturn(transaction); + when(thirdMiniAppWechatService.close(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())) + .thenReturn(false); + + service.queryPayResult(expiredOrder); + + verify(thirdMiniAppWechatService).close(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson()); + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + /** + * 验证商城支付以硬件订单归属商户为真源,远程 DTO 商户不一致时拒绝创建支付。 + */ + @Test + void miniPayShouldRejectShopOrderMerchantMismatch() { + ThirdMiniRemotePayDto payRequest = shopPayRequest(99L); + HardOrderEntity hardOrder = hardOrder(88L); + when(remoteHardOrderService.getByIds(List.of(hardOrder.getId()))).thenReturn(R.ok(List.of(hardOrder))); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, + () -> service.miniPay(payRequest.getUserId(), payRequest)); + + Assertions.assertTrue(exception.getMessage().contains("收款商户不一致")); + verify(thirdMiniAppAliPayService, never()).appPay(any(), any()); + } + + /** + * 验证商城支付缺少直付通 smid 时在第三方下单前失败。 + */ + @Test + void miniPayShouldRejectMissingDirectPaySmid() { + ThirdMiniRemotePayDto payRequest = shopPayRequest(88L); + HardOrderEntity hardOrder = hardOrder(88L); + MchManageVo shopMerchant = enabledAliPayMerchant(); + MchPaySettingVo setting = shopMerchant.getPaySettingList().get(0); + setting.setSettingJson("{}"); + when(remoteHardOrderService.getByIds(List.of(hardOrder.getId()))).thenReturn(R.ok(List.of(hardOrder))); + when(remoteMchService.getById(88L)).thenReturn(R.ok(shopMerchant)); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, + () -> service.miniPay(payRequest.getUserId(), payRequest)); + + Assertions.assertTrue(exception.getMessage().contains("二级商户标识未配置")); + verify(thirdMiniAppAliPayService, never()).appPay(any(), any()); + } + + /** + * 验证商城支付只把硬件订单持久化的收款商户写入第三方支付 DTO。 + */ + @Test + void miniPayShouldUseTrustedShopOrderMerchant() { + ThirdMiniRemotePayDto payRequest = shopPayRequest(88L); + HardOrderEntity hardOrder = hardOrder(88L); + MchManageVo shopMerchant = enabledAliPayMerchant(); + when(remoteHardOrderService.getByIds(List.of(hardOrder.getId()))).thenReturn(R.ok(List.of(hardOrder))); + when(remoteMchService.getById(88L)).thenReturn(R.ok(shopMerchant)); + when(thirdMiniAppAliPayService.appPay(any(), any())).thenReturn("signed-order"); + + service.miniPay(payRequest.getUserId(), payRequest); + + ArgumentCaptor payDtoCaptor = ArgumentCaptor.forClass(ThirdAppPlusPayDto.class); + verify(thirdMiniAppAliPayService).appPay(payDtoCaptor.capture(), any()); + Assertions.assertEquals(88L, payDtoCaptor.getValue().getMchId()); + Assertions.assertEquals("smid-1001", payDtoCaptor.getValue().getSubMchid()); + Assertions.assertEquals(hardOrder.getPayPrice(), payDtoCaptor.getValue().getAmount()); + } + + /** + * 验证微信退款 PROCESSING 不提前推进本地订单终态。 + */ + @Test + void queryRefundResultShouldKeepWechatProcessingState() { + ThirdPayOrderEntity refundingOrder = refundingOrder(201L, "refund-processing"); + ThirdMiniWechatRefundQueryVo query = new ThirdMiniWechatRefundQueryVo(); + query.setRefund_status("PROCESSING"); + when(thirdMiniAppWechatService.refundQuery(refundingOrder.getPayNo())).thenReturn(query); + + service.queryRefundResult(refundingOrder); + + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + /** + * 验证微信 SUCCESS、CLOSED、ABNORMAL 分别映射退款成功或失败终态。 + */ + @Test + void queryRefundResultShouldMapWechatTerminalStates() { + ThirdPayOrderEntity successOrder = refundingOrder(202L, "refund-success"); + ThirdPayOrderEntity closedOrder = refundingOrder(203L, "refund-closed"); + ThirdPayOrderEntity abnormalOrder = refundingOrder(204L, "refund-abnormal"); + ThirdMiniWechatRefundQueryVo success = new ThirdMiniWechatRefundQueryVo(); + success.setRefund_status("SUCCESS"); + ThirdMiniWechatRefundQueryVo closed = new ThirdMiniWechatRefundQueryVo(); + closed.setRefund_status("CLOSED"); + ThirdMiniWechatRefundQueryVo abnormal = new ThirdMiniWechatRefundQueryVo(); + abnormal.setRefund_status("ABNORMAL"); + when(thirdMiniAppWechatService.refundQuery(any())).thenReturn(success, closed, abnormal); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + ThirdPayOrderEntity latestSuccess = refundingOrder(successOrder.getId(), successOrder.getRefundNo()); + latestSuccess.setStatus(ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType()); + latestSuccess.setIsBizCallback(YesNoEnum.YES.getCode()); + when(thirdPayOrderService.getById(successOrder.getId())).thenReturn(latestSuccess); + + service.queryRefundResult(successOrder); + service.queryRefundResult(closedOrder); + service.queryRefundResult(abnormalOrder); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService, org.mockito.Mockito.times(3)).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType(), updateCaptor.getAllValues().get(0).getStatus()); + Assertions.assertEquals(ThirdPayOrderStatusEnum.REFUND_FAILED.getType(), updateCaptor.getAllValues().get(1).getStatus()); + Assertions.assertEquals(ThirdPayOrderStatusEnum.REFUND_FAILED.getType(), updateCaptor.getAllValues().get(2).getStatus()); + } + private void mockNoExistingOrder() { when(thirdPayOrderService.count(any(Wrapper.class))).thenReturn(0L); when(thirdPayOrderService.getOne(any(Wrapper.class))).thenReturn(null); @@ -473,9 +629,11 @@ class ThirdAppPlusServiceImplOrderCodePayTest { private MchManageVo enabledAliPayMerchant() { MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting(); aliPaySetting.setAppAuthToken("merchant-auth-token"); + aliPaySetting.setSubMchid("smid-1001"); MchPaySettingVo setting = new MchPaySettingVo(); setting.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType()); setting.setEnable(YesNoEnum.YES.getCode()); + setting.setFzRatio(10D); setting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); MchManageVo result = new MchManageVo(); result.setId(88L); @@ -483,6 +641,55 @@ class ThirdAppPlusServiceImplOrderCodePayTest { return result; } + /** + * 构造服务端商城支付请求,mchId 只用于与硬件订单真源做一致性校验。 + * + * @param mchId 远程 DTO 携带的商户 ID + * @return 商城支付请求 + */ + private ThirdMiniRemotePayDto shopPayRequest(Long mchId) { + ThirdMiniRemotePayDto payRequest = new ThirdMiniRemotePayDto(); + payRequest.setUserId(10L); + payRequest.setMchId(mchId); + payRequest.setBizId("7001"); + payRequest.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY); + payRequest.setMiniPlatformType(AppPlusPlatformTypeEnum.ALI_PAY); + payRequest.setAmount(1234); + return payRequest; + } + + /** + * 构造带可信用户、金额和收款商户的硬件商城订单。 + * + * @param mchId 数据库订单归属商户 ID + * @return 硬件商城订单 + */ + private HardOrderEntity hardOrder(Long mchId) { + HardOrderEntity hardOrder = new HardOrderEntity(); + hardOrder.setId(7001L); + hardOrder.setUserId(10L); + hardOrder.setMerchantId(mchId); + hardOrder.setPayPrice(1234); + return hardOrder; + } + + /** + * 构造微信退款中的本地支付订单。 + * + * @param id 支付订单主键 + * @param refundNo 稳定退款单号 + * @return 退款轮询订单 + */ + private ThirdPayOrderEntity refundingOrder(Long id, String refundNo) { + ThirdPayOrderEntity order = new ThirdPayOrderEntity(); + order.setId(id); + order.setPayNo("pay-" + id); + order.setRefundNo(refundNo); + order.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType()); + order.setStatus(ThirdPayOrderStatusEnum.REFUNDING.getType()); + return order; + } + private MchManageVo enabledWechatMerchant() { MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); wechatPaySetting.setSubMchid("1900000109"); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java new file mode 100644 index 000000000..ebb8df38a --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java @@ -0,0 +1,199 @@ +package com.bsd.pjcloud.thirdparty.service.impl.appplus; + +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.fz.ThirdFzOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdFzOrderMapper; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayOrderMapper; +import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService; +import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer; +import com.bsd.pjcloud.thirdparty.service.impl.appplus.fz.ThirdAppPlusWechatFzServiceImpl; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.wechat.pay.java.service.profitsharing.ProfitsharingService; +import com.wechat.pay.java.service.profitsharing.model.OrderStatus; +import com.wechat.pay.java.service.profitsharing.model.OrdersEntity; +import com.wechat.pay.java.service.profitsharing.model.QueryOrderRequest; +import com.wechat.pay.java.service.profitsharing.model.QueryReturnOrderRequest; +import com.wechat.pay.java.service.profitsharing.model.ReturnOrderStatus; +import com.wechat.pay.java.service.profitsharing.model.ReturnOrdersEntity; +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.util.List; + +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 ThirdAppPlusWechatFzServiceImplTest { + + /** 微信平台配置 Mock,仅补齐构造依赖。 */ + @Mock + private AppPlusPayConfig appPlusPayConfig; + + /** 微信分账 SDK 服务 Mock。 */ + @Mock + private ProfitsharingService profitsharingService; + + /** 分账订单服务 Mock,用于捕获带原状态条件的更新。 */ + @Mock + private ThirdFzOrderService fzOrderService; + + /** 支付订单服务 Mock,用于读取配置快照并更新最终抽佣标记。 */ + @Mock + private ThirdPayOrderService thirdPayOrderService; + + /** 支付订单 Mapper Mock,仅补齐构造依赖。 */ + @Mock + private ThirdPayOrderMapper thirdPayOrderMapper; + + /** 分账订单 Mapper Mock,仅补齐构造依赖。 */ + @Mock + private ThirdFzOrderMapper thirdFzOrderMapper; + + /** 支付任务写入器 Mock,用于补齐微信退分账实时恢复任务依赖。 */ + @Mock + private ThirdPaymentTaskEnqueuer paymentTaskEnqueuer; + + /** 被测微信分账服务。 */ + @InjectMocks + private ThirdAppPlusWechatFzServiceImpl service; + + /** 公共支付订单快照。 */ + private ThirdPayOrderEntity payOrder; + + /** + * 准备微信子商户配置快照。 + */ + @BeforeEach + void setUp() { + MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); + wechatPaySetting.setSubMchid("wx-sub-1001"); + MchPaySettingEntity setting = new MchPaySettingEntity(); + setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); + payOrder = new ThirdPayOrderEntity(); + payOrder.setId(1L); + payOrder.setPayNo("pay-1001"); + payOrder.setThirdPayNo("wx-transaction-1001"); + payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting)); + } + + /** + * 验证微信 PROCESSING 只刷新快照,不提前标记分账成功。 + */ + @Test + void pollFzOrderShouldKeepProcessingState() { + ThirdFzOrderEntity fzOrder = fzOrder(1L, ThirdFzOrderStatusEnum.FZ_UNPAID); + OrdersEntity response = new OrdersEntity(); + response.setState(OrderStatus.PROCESSING); + when(thirdPayOrderService.getByPayNo(fzOrder.getPayNo())).thenReturn(payOrder); + when(profitsharingService.queryOrder(any(QueryOrderRequest.class))).thenReturn(response); + + service.pollFzOrder(fzOrder); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdFzOrderEntity.class); + verify(fzOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertNull(updateCaptor.getValue().getStatus()); + Assertions.assertEquals("PROCESSING", JSONUtil.parseObj(updateCaptor.getValue().getCreateRes()) + .getJSONObject("royalty").getStr("status")); + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + /** + * 验证微信 FINISHED 才同时推进分账记录和支付订单最终抽佣标记。 + */ + @Test + void pollFzOrderShouldMarkSuccessOnlyWhenFinished() { + ThirdFzOrderEntity fzOrder = fzOrder(2L, ThirdFzOrderStatusEnum.FZ_UNPAID); + OrdersEntity response = new OrdersEntity(); + response.setState(OrderStatus.FINISHED); + when(thirdPayOrderService.getByPayNo(fzOrder.getPayNo())).thenReturn(payOrder); + when(profitsharingService.queryOrder(any(QueryOrderRequest.class))).thenReturn(response); + when(fzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.pollFzOrder(fzOrder); + + ArgumentCaptor fzUpdateCaptor = ArgumentCaptor.forClass(ThirdFzOrderEntity.class); + verify(fzOrderService).update(fzUpdateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_SUCCESS.getType(), fzUpdateCaptor.getValue().getStatus()); + ArgumentCaptor payUpdateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).update(payUpdateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(YesNoEnum.YES.getCode(), payUpdateCaptor.getValue().getIsFzSuccess()); + Assertions.assertEquals(fzOrder.getPrice(), payUpdateCaptor.getValue().getFzPrice()); + } + + /** + * 验证退分账 PROCESSING 保持原状态,SUCCESS 和 FAILED 分别进入对应终态。 + */ + @Test + void pollFzRefundOrderShouldMapWechatReturnStatuses() { + ThirdFzOrderEntity processing = fzOrder(11L, ThirdFzOrderStatusEnum.FZ_REFUNDING); + ThirdFzOrderEntity success = fzOrder(12L, ThirdFzOrderStatusEnum.FZ_REFUNDING); + ThirdFzOrderEntity failed = fzOrder(13L, ThirdFzOrderStatusEnum.FZ_REFUNDING); + ReturnOrdersEntity processingResponse = returnOrder(ReturnOrderStatus.PROCESSING); + ReturnOrdersEntity successResponse = returnOrder(ReturnOrderStatus.SUCCESS); + ReturnOrdersEntity failedResponse = returnOrder(ReturnOrderStatus.FAILED); + when(thirdPayOrderService.getByPayNo(any())).thenReturn(payOrder); + when(profitsharingService.queryReturnOrder(any(QueryReturnOrderRequest.class))) + .thenReturn(processingResponse, successResponse, failedResponse); + + service.pollFzRefundOrder(processing); + service.pollFzRefundOrder(success); + service.pollFzRefundOrder(failed); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdFzOrderEntity.class); + verify(fzOrderService, org.mockito.Mockito.times(3)).update(updateCaptor.capture(), any(Wrapper.class)); + List updates = updateCaptor.getAllValues(); + Assertions.assertNull(updates.get(0).getStatus()); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType(), updates.get(1).getStatus()); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType(), updates.get(2).getStatus()); + } + + /** + * 构造稳定分账号和退分账号的分账记录。 + * + * @param id 分账记录主键 + * @param status 本地分账状态 + * @return 待轮询分账记录 + */ + private ThirdFzOrderEntity fzOrder(Long id, ThirdFzOrderStatusEnum status) { + ThirdFzOrderEntity fzOrder = new ThirdFzOrderEntity(); + fzOrder.setId(id); + fzOrder.setPayNo(payOrder.getPayNo()); + fzOrder.setFzNo("fz-pay-1001"); + fzOrder.setFzRefundNo("fz-refund-pay-1001"); + fzOrder.setPrice(123); + fzOrder.setStatus(status.getType()); + return fzOrder; + } + + /** + * 构造指定微信退分账状态的查询响应。 + * + * @param status 微信退分账状态 + * @return 查询响应 + */ + private ReturnOrdersEntity returnOrder(ReturnOrderStatus status) { + ReturnOrdersEntity response = new ReturnOrdersEntity(); + response.setResult(status); + response.setOutOrderNo("fz-pay-1001"); + response.setOutReturnNo("fz-refund-pay-1001"); + return response; + } +} 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 index a436cf113..59ae18b44 100644 --- 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 @@ -29,6 +29,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; import java.util.Map; @@ -83,6 +84,7 @@ class ThirdAppPlusWechatServiceImplPayCallbackTest { @BeforeEach void setUp() { + ReflectionTestUtils.setField(service, "aggregatePaySessionService", aggregatePaySessionService); AppPlusPayConfig.WechatConfig wechatConfig = new AppPlusPayConfig.WechatConfig(); wechatConfig.setAppPlatformAppId("wx-service-app-id"); wechatConfig.setGzhAppId("wx-service-jsapi-appid"); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java new file mode 100644 index 000000000..379dd4bb6 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java @@ -0,0 +1,276 @@ +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.thirdparty.config.AppPlusPayConfig; +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.dto.appplus.pay.wechat.ThirdMiniWechatRefundDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import com.bsd.pjcloud.thirdparty.service.impl.appplus.fz.lifecycle.ThirdAppPlusWechatFzLifecycle; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.wechat.ThirdMiniWechatRefundQueryVo; +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.service.partnerpayments.app.AppServiceExtension; +import com.wechat.pay.java.service.partnerpayments.app.model.CloseOrderRequest; +import com.wechat.pay.java.service.partnerpayments.app.model.PrepayRequest; +import com.wechat.pay.java.service.partnerpayments.app.model.PrepayWithRequestPaymentResponse; +import com.wechat.pay.java.service.refund.RefundService; +import com.wechat.pay.java.service.refund.model.Amount; +import com.wechat.pay.java.service.refund.model.CreateRequest; +import com.wechat.pay.java.service.refund.model.QueryByOutRefundNoRequest; +import com.wechat.pay.java.service.refund.model.Refund; +import com.wechat.pay.java.service.refund.model.Status; +import org.junit.jupiter.api.Assertions; +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.OffsetDateTime; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * 微信普通退款查单协议测试,确保服务商子商户快照和外部状态完整映射。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdAppPlusWechatServiceImplRefundTest { + + /** 微信支付应用配置 Mock。 */ + @Mock + private AppPlusPayConfig appPlusPayConfig; + + /** 微信小程序能力 Mock,仅补齐被测服务构造依赖。 */ + @Mock + private WxMaService wxMaService; + + /** 支付订单服务 Mock,提供退款中的订单快照。 */ + @Mock + private ThirdPayOrderService thirdPayOrderService; + + /** 微信服务商 App 支付服务 Mock,仅补齐构造依赖。 */ + @Mock + private AppServiceExtension appServiceExtension; + + /** 微信退款服务 Mock,用于捕获退款查询请求。 */ + @Mock + private RefundService refundService; + + /** 微信回调解析器 Mock,仅补齐构造依赖。 */ + @Mock + private NotificationParser notificationParser; + + /** 微信分账生命周期 Mock,仅补齐构造依赖。 */ + @Mock + private ThirdAppPlusWechatFzLifecycle appPlusWechatFzLifecycle; + + /** 被测微信 App 支付服务。 */ + @InjectMocks + private ThirdAppPlusWechatServiceImpl service; + + /** + * 验证 SUCCESS、CLOSED、ABNORMAL、PROCESSING 均按微信原始编码返回上层状态机。 + */ + @Test + void refundQueryShouldMapAllWechatStatusesAndUseSnapshotSubMerchant() { + ThirdPayOrderEntity payOrder = refundingOrder(); + when(thirdPayOrderService.getByPayNo(payOrder.getPayNo())).thenReturn(payOrder); + + for (Status status : Status.values()) { + Refund refund = refund(status); + when(refundService.queryByOutRefundNo(any(QueryByOutRefundNoRequest.class))).thenReturn(refund); + + ThirdMiniWechatRefundQueryVo result = service.refundQuery(payOrder.getPayNo()); + + Assertions.assertNotNull(result); + Assertions.assertEquals(status.name(), result.getRefund_status()); + Assertions.assertEquals(500L, result.getRefund_fee()); + Assertions.assertEquals(1234L, result.getTotal_fee()); + } + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(QueryByOutRefundNoRequest.class); + verify(refundService, org.mockito.Mockito.times(Status.values().length)) + .queryByOutRefundNo(requestCaptor.capture()); + Assertions.assertTrue(requestCaptor.getAllValues().stream() + .allMatch(request -> "refund-pay-1001".equals(request.getOutRefundNo()) + && "wx-sub-1001".equals(request.getSubMchid()))); + } + + /** + * 验证微信退款创建同步返回只对 SUCCESS、CLOSED、ABNORMAL 推进终态,PROCESSING 继续等待回调或轮询。 + */ + @Test + void refundShouldApplySynchronousTerminalStatusWithoutAdvancingProcessing() { + AppPlusPayConfig.WechatConfig config = new AppPlusPayConfig.WechatConfig(); + config.setBaseCallbackUrl("https://pay.example.com"); + config.setRefundCallbackUrl("/refund-callback"); + when(appPlusPayConfig.getWechatConfig()).thenReturn(config); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + for (Status status : Status.values()) { + ThirdPayOrderEntity payOrder = paidRefundableOrder(); + when(refundService.create(any(CreateRequest.class))).thenReturn(refund(status)); + when(thirdPayOrderService.getByPayNo(payOrder.getPayNo())).thenReturn(payOrder); + ThirdMiniWechatRefundDto dto = new ThirdMiniWechatRefundDto(); + dto.setPayNo(payOrder.getPayNo()); + dto.setRefundPrice(500); + + service.refund(payOrder, dto); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService, org.mockito.Mockito.times(2)) + .update(updateCaptor.capture(), any(Wrapper.class)); + ThirdPayOrderEntity responseUpdate = updateCaptor.getAllValues().get(1); + if (status == Status.SUCCESS) { + Assertions.assertEquals(ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType(), responseUpdate.getStatus()); + } else if (status == Status.CLOSED || status == Status.ABNORMAL) { + Assertions.assertEquals(ThirdPayOrderStatusEnum.REFUND_FAILED.getType(), responseUpdate.getStatus()); + } else { + Assertions.assertNull(responseUpdate.getStatus()); + } + clearInvocations(thirdPayOrderService, refundService); + } + } + + /** + * 验证非法微信分账比例在创建渠道支付前被拒绝,不会留下无法完成抽佣的已支付交易。 + */ + @Test + void appPayShouldRejectInvalidProfitSharingRatioBeforeCreatingOrder() { + MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); + wechatPaySetting.setSubMchid("wx-sub-1001"); + MchPaySettingVo setting = new MchPaySettingVo(); + setting.setFzRatio(101D); + setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); + ThirdAppPlusPayDto dto = new ThirdAppPlusPayDto(); + dto.setAmount(1000); + dto.setOutTradeNo("pay-invalid-ratio"); + dto.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, + () -> service.appPay(dto, setting)); + + Assertions.assertTrue(exception.getMessage().contains("不超过100%")); + verifyNoInteractions(appServiceExtension); + verify(thirdPayOrderService, org.mockito.Mockito.never()).save(any(ThirdPayOrderEntity.class)); + } + + /** + * 验证微信 App 预下单把本地保存的十五分钟截止时间同步传给渠道,避免本地和微信订单生命周期分叉。 + */ + @Test + void appPayShouldSendSameExpireTimeToWechatAndPersistedOrder() { + AppPlusPayConfig.WechatConfig config = new AppPlusPayConfig.WechatConfig(); + config.setAppPlatformAppId("wx-app-id"); + config.setMerchantId("wx-service-mch"); + config.setBaseCallbackUrl("https://pay.example.com"); + config.setPayCallbackUrl("/pay-callback"); + when(appPlusPayConfig.getWechatConfig()).thenReturn(config); + PrepayWithRequestPaymentResponse response = new PrepayWithRequestPaymentResponse(); + response.setPrepayId("prepay-id"); + when(appServiceExtension.prepayWithRequestPayment(any(PrepayRequest.class), any())).thenReturn(response); + + MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); + wechatPaySetting.setSubMchid("wx-sub-1001"); + MchPaySettingVo setting = new MchPaySettingVo(); + setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); + ThirdAppPlusPayDto dto = new ThirdAppPlusPayDto(); + dto.setAmount(1234); + dto.setOutTradeNo("pay-expire-1001"); + dto.setBizId("order-1001"); + dto.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY); + dto.setMchId(88L); + + service.appPay(dto, setting); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PrepayRequest.class); + verify(appServiceExtension).prepayWithRequestPayment(requestCaptor.capture(), any()); + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).save(orderCaptor.capture()); + OffsetDateTime channelExpireTime = OffsetDateTime.parse(requestCaptor.getValue().getTimeExpire()); + Assertions.assertEquals(orderCaptor.getValue().getExpireTime().withNano(0), channelExpireTime.toLocalDateTime()); + Assertions.assertTrue(orderCaptor.getValue().getExpireTime().isAfter(java.time.LocalDateTime.now().plusMinutes(14))); + } + + /** + * 验证微信 App 关单只使用订单配置快照中的子商户号,且 SDK 异常不能被误判为关单成功。 + */ + @Test + void closeShouldUseSnapshotSubMerchantAndKeepUnknownResultRetryable() { + AppPlusPayConfig.WechatConfig config = new AppPlusPayConfig.WechatConfig(); + config.setMerchantId("wx-service-mch"); + when(appPlusPayConfig.getWechatConfig()).thenReturn(config); + ThirdPayOrderEntity order = refundingOrder(); + + Assertions.assertTrue(service.close(order.getPayNo(), order.getMchPaySettingJson())); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(CloseOrderRequest.class); + verify(appServiceExtension).closeOrder(requestCaptor.capture()); + Assertions.assertEquals(order.getPayNo(), requestCaptor.getValue().getOutTradeNo()); + Assertions.assertEquals("wx-service-mch", requestCaptor.getValue().getSpMchid()); + Assertions.assertEquals("wx-sub-1001", requestCaptor.getValue().getSubMchid()); + + doThrow(new RuntimeException("network")).when(appServiceExtension).closeOrder(any(CloseOrderRequest.class)); + Assertions.assertFalse(service.close(order.getPayNo(), order.getMchPaySettingJson())); + } + + /** + * 构造包含微信子商户快照的退款中订单。 + * + * @return 退款查询所需支付订单 + */ + private ThirdPayOrderEntity refundingOrder() { + MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); + wechatPaySetting.setSubMchid("wx-sub-1001"); + MchPaySettingEntity setting = new MchPaySettingEntity(); + setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); + ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity(); + payOrder.setId(1L); + payOrder.setPayNo("pay-1001"); + payOrder.setRefundNo("refund-pay-1001"); + payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting)); + return payOrder; + } + + /** + * 构造可发起普通退款的已支付微信订单。 + * + * @return 包含金额、状态和子商户配置快照的支付订单 + */ + private ThirdPayOrderEntity paidRefundableOrder() { + ThirdPayOrderEntity payOrder = refundingOrder(); + payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); + payOrder.setPrice(1234); + return payOrder; + } + + /** + * 构造指定状态的微信退款查询响应。 + * + * @param status 微信退款状态 + * @return 包含订单号和金额的退款响应 + */ + private Refund refund(Status status) { + Amount amount = new Amount(); + amount.setRefund(500L); + amount.setTotal(1234L); + Refund refund = new Refund(); + refund.setOutTradeNo("pay-1001"); + refund.setTransactionId("wx-transaction-1001"); + refund.setStatus(status); + refund.setAmount(amount); + return refund; + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImplTest.java new file mode 100644 index 000000000..ff87a0b95 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImplTest.java @@ -0,0 +1,204 @@ +package com.bsd.pjcloud.thirdparty.service.impl.pay; + +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.entity.SysUser; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskStatusEnum; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayOrderDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPaymentTaskEntity; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayOrderMapper; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskMapper; +import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayOrderVo; +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 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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +/** + * 支付订单管理端投影测试,覆盖商户归属、直付通 smid、分账阶段和敏感配置脱敏。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdPayOrderServiceImplTest { + + /** 支付订单 Mapper Mock,返回固定分页。 */ + @Mock + private ThirdPayOrderMapper thirdPayOrderMapper; + + /** 用户远程服务 Mock,补充支付用户名称。 */ + @Mock + private RemoteUserService remoteUserService; + + /** 商户远程服务 Mock,补充可信商户名称。 */ + @Mock + private RemoteMchService remoteMchService; + + /** 分账订单服务 Mock,返回结构化阶段快照。 */ + @Mock + private ThirdFzOrderService thirdFzOrderService; + + /** 支付任务 Mapper Mock,用于验证同一支付订单的运营处置任务选择规则。 */ + @Mock + private ThirdPaymentTaskMapper thirdPaymentTaskMapper; + + /** 被测支付订单服务。 */ + @InjectMocks + private ThirdPayOrderServiceImpl service; + + /** + * 将 MyBatis-Plus 基类 Mapper 指向当前 Mock。 + */ + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "baseMapper", thirdPayOrderMapper); + ReflectionTestUtils.setField(service, "thirdPaymentTaskMapper", thirdPaymentTaskMapper); + lenient().when(thirdPaymentTaskMapper.selectList(any(Wrapper.class))).thenReturn(List.of()); + } + + /** + * 支付宝直付通订单应独立展示确认结算与平台抽佣,且浏览器不能获得完整商户配置快照。 + */ + @Test + void scopePageShouldProjectDirectPayStagesAndRemoveSettingSnapshot() { + ThirdPayOrderVo order = alipayOrder(); + Page page = new Page<>(1, 10); + page.setRecords(List.of(order)); + when(thirdPayOrderMapper.scopePage(any(Page.class), any(ThirdPayOrderDto.class), isNull())).thenReturn(page); + + SysUser user = new SysUser(); + user.setUserId(order.getUserId()); + user.setName("测试用户"); + when(remoteUserService.getInnerUserById(List.of(order.getUserId()))).thenReturn(R.ok(List.of(user))); + MchManageVo merchant = new MchManageVo(); + merchant.setId(order.getMchId()); + merchant.setName("测试商户"); + when(remoteMchService.getByIds(List.of(order.getMchId()))).thenReturn(R.ok(List.of(merchant))); + when(thirdFzOrderService.list(any(Wrapper.class))).thenReturn(List.of(fzOrder(order.getPayNo()))); + + ThirdPayOrderVo result = service.scopePage(new Page<>(1, 10), new ThirdPayOrderDto(), null).getRecords().get(0); + + Assertions.assertEquals("测试商户", result.getMchName()); + Assertions.assertEquals("smid-1001", result.getSmid()); + Assertions.assertEquals("SUCCESS", result.getSettleConfirmStatus()); + Assertions.assertEquals("PROCESSING", result.getRoyaltyStatus()); + Assertions.assertEquals("NOT_STARTED", result.getFzRefundStatus()); + Assertions.assertNotNull(result.getSettleConfirmReq()); + Assertions.assertNotNull(result.getRoyaltyRes()); + Assertions.assertNull(result.getMchPaySettingJson()); + } + + /** + * 支付宝仅完成确认结算时,历史 createReq 不得被误投影为尚未发起的平台抽佣请求。 + */ + @Test + void directPayConfirmRequestShouldNotBeDisplayedAsRoyaltyRequest() { + ThirdPayOrderVo order = alipayOrder(); + ThirdFzOrderEntity fzOrder = new ThirdFzOrderEntity(); + fzOrder.setPayNo(order.getPayNo()); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_UNPAID.getType()); + fzOrder.setCreateReq(JSONUtil.createObj().set("outRequestNo", "settle_pay-1001").toString()); + fzOrder.setCreateRes(JSONUtil.createObj() + .set("provider", "ALIPAY") + .set("settleConfirm", JSONUtil.createObj() + .set("status", "SUCCESS") + .set("request", JSONUtil.createObj().set("outRequestNo", "settle_pay-1001"))) + .toString()); + + ReflectionTestUtils.invokeMethod(service, "fillProfitSharingStage", order, fzOrder); + + Assertions.assertEquals("SUCCESS", order.getSettleConfirmStatus()); + Assertions.assertEquals("NOT_STARTED", order.getRoyaltyStatus()); + Assertions.assertNull(order.getRoyaltyReq()); + } + + /** + * 验证同一支付单的告警任务优先于较新的成功任务展示,人工重试不会误选已完成任务。 + */ + @Test + void taskProjectionShouldPreferActionableAlertOverNewerSuccess() { + ThirdPayOrderVo order = alipayOrder(); + ThirdPaymentTaskEntity success = new ThirdPaymentTaskEntity(); + success.setId(101L); + success.setPayNo(order.getPayNo()); + success.setStatus(ThirdPaymentTaskStatusEnum.SUCCESS.getCode()); + ThirdPaymentTaskEntity alert = new ThirdPaymentTaskEntity(); + alert.setId(102L); + alert.setPayNo(order.getPayNo()); + alert.setStatus(ThirdPaymentTaskStatusEnum.ALERT.getCode()); + alert.setAttemptCount(15); + alert.setLastErrorMessage("渠道状态长期未收敛"); + when(thirdPaymentTaskMapper.selectList(any(Wrapper.class))).thenReturn(List.of(success, alert)); + + ReflectionTestUtils.invokeMethod(service, "fillTaskStates", List.of(order)); + + Assertions.assertEquals(alert.getId(), order.getTaskId()); + Assertions.assertEquals(ThirdPaymentTaskStatusEnum.ALERT.getCode(), order.getTaskStatus()); + Assertions.assertEquals(15, order.getTaskAttemptCount()); + } + + /** + * 构造带完整支付宝商户快照的支付订单,用于验证出参只保留 smid。 + * + * @return 支付宝直付通支付订单视图 + */ + private ThirdPayOrderVo alipayOrder() { + MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting(); + aliPaySetting.setSubMchid("smid-1001"); + aliPaySetting.setAppAuthToken("sensitive-auth-token"); + MchPaySettingEntity setting = new MchPaySettingEntity(); + setting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); + + ThirdPayOrderVo order = new ThirdPayOrderVo(); + order.setId(1L); + order.setPayNo("pay-1001"); + order.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType()); + order.setUserId(10L); + order.setMchId(20L); + order.setEnableFz(YesNoEnum.YES.getCode()); + order.setMchPaySettingJson(JSONUtil.toJsonStr(setting)); + return order; + } + + /** + * 构造确认结算成功但平台抽佣仍在处理中的分账快照。 + * + * @param payNo 对应支付订单号 + * @return 分账订单 + */ + private ThirdFzOrderEntity fzOrder(String payNo) { + ThirdFzOrderEntity fzOrder = new ThirdFzOrderEntity(); + fzOrder.setPayNo(payNo); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_UNPAID.getType()); + fzOrder.setCreateRes(JSONUtil.createObj() + .set("provider", "ALIPAY") + .set("settleConfirm", JSONUtil.createObj() + .set("status", "SUCCESS") + .set("request", JSONUtil.createObj().set("outRequestNo", "settle_pay-1001"))) + .set("royalty", JSONUtil.createObj() + .set("status", "PROCESSING") + .set("response", JSONUtil.createObj().set("subCode", "SYSTEM_ERROR"))) + .toString()); + return fzOrder; + } +} diff --git a/pjcloud-visual/pjcloud-daemon-quartz/pom.xml b/pjcloud-visual/pjcloud-daemon-quartz/pom.xml index d7f33fbf1..1bb34e64c 100644 --- a/pjcloud-visual/pjcloud-daemon-quartz/pom.xml +++ b/pjcloud-visual/pjcloud-daemon-quartz/pom.xml @@ -15,6 +15,12 @@ 基于quartz后台跑批定时任务模块 + + + com.bsd + pjcloud-thirdparty-api + 5.7.0 + com.bsd pjcloud-daemon-quartz-api diff --git a/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializer.java b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializer.java new file mode 100644 index 000000000..66c52c9c8 --- /dev/null +++ b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializer.java @@ -0,0 +1,96 @@ +package com.bsd.pjcloud.daemon.quartz.config; + +import com.bsd.pjcloud.daemon.quartz.job.ThirdPaymentDispatchJob; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.*; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +/** + * 代码托管的支付任务触发器,不受后台 {@code sys_job} 增删、暂停或频率修改影响。 + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class ThirdPaymentQuartzInitializer implements InitializingBean { + /** 支付固定任务所属 Quartz 分组。 */ + private static final String GROUP = "THIRD_PAYMENT_SYSTEM"; + /** 每轮默认最大领取数量。 */ + private static final int DEFAULT_BATCH_SIZE = 100; + /** Quartz调度器。 */ + private final Scheduler scheduler; + + /** + * 支付任务与固定触发间隔;同一 JobKey 在 Quartz 集群中同一时刻只会由一个节点执行。 + */ + private static final List SCHEDULES = List.of( + new PaymentSchedule(ThirdPaymentTaskTypeEnum.PAY_CREATE, 5), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.PAY_QUERY, 5), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.REFUND_CREATE, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.REFUND_QUERY, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_CREATE, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_SETTLE_CONFIRM, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_ROYALTY, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_ROYALTY_QUERY, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_QUERY, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_REFUND_QUERY, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.BIZ_CALLBACK, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.AGGREGATE_SESSION, 5), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.PAY_CLOSE, 300), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.AGGREGATE_CLOSE, 300), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.RECONCILE, 300)); + + /** + * 幂等注册固定任务。多副本同时启动时,Quartz数据库唯一键裁决创建竞争。 + * @throws SchedulerException Quartz持久化失败 + */ + @Override + public void afterPropertiesSet() throws SchedulerException { + for (PaymentSchedule schedule : SCHEDULES) { + register(schedule); + } + } + + /** + * 创建或覆盖一个代码托管 Job,并重置其固定频率。 + * @param schedule 任务类型和秒级间隔 + * @throws SchedulerException Quartz持久化失败 + */ + private void register(PaymentSchedule schedule) throws SchedulerException { + String name = "third-payment-" + schedule.taskType().getCode().toLowerCase().replace('_', '-'); + JobKey jobKey = JobKey.jobKey(name, GROUP); + TriggerKey triggerKey = TriggerKey.triggerKey(name + "-trigger", GROUP); + JobDetail job = JobBuilder.newJob(ThirdPaymentDispatchJob.class).withIdentity(jobKey) + .usingJobData(ThirdPaymentDispatchJob.TASK_TYPE_KEY, schedule.taskType().getCode()) + .usingJobData(ThirdPaymentDispatchJob.BATCH_SIZE_KEY, DEFAULT_BATCH_SIZE) + .requestRecovery(true).storeDurably(true).build(); + SimpleScheduleBuilder simpleSchedule = SimpleScheduleBuilder.simpleSchedule() + .withIntervalInSeconds(schedule.intervalSeconds()).repeatForever() + .withMisfireHandlingInstructionNextWithRemainingCount(); + Trigger trigger = TriggerBuilder.newTrigger().withIdentity(triggerKey).forJob(jobKey) + .startNow().withSchedule(simpleSchedule).build(); + try { + scheduler.addJob(job, true); + if (scheduler.checkExists(triggerKey)) { + scheduler.rescheduleJob(triggerKey, trigger); + } else { + scheduler.scheduleJob(trigger); + } + } catch (ObjectAlreadyExistsException exception) { + // 多节点同时首次部署时另一个实例已完成注册,读取共享QRTZ表即可。 + log.info("支付Quartz任务已由其他集群节点初始化,jobKey:{}", jobKey); + } + } + + /** + * 固定任务频率配置。 + * @param taskType 支付任务类型 + * @param intervalSeconds 触发间隔,单位秒 + */ + private record PaymentSchedule(ThirdPaymentTaskTypeEnum taskType, int intervalSeconds) { + } +} diff --git a/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/job/ThirdPaymentDispatchJob.java b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/job/ThirdPaymentDispatchJob.java new file mode 100644 index 000000000..90143324d --- /dev/null +++ b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/job/ThirdPaymentDispatchJob.java @@ -0,0 +1,58 @@ +package com.bsd.pjcloud.daemon.quartz.job; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.thirdparty.api.feign.RemoteThirdPaymentTaskService; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPaymentTaskDispatchDto; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPaymentTaskDispatchVo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.*; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDateTime; + +/** + * 支付任务固定 Quartz Job,只同步确认 thirdparty 接受分发,不直接访问支付 SDK。 + */ +@Slf4j +@DisallowConcurrentExecution +public class ThirdPaymentDispatchJob implements Job { + /** JobDataMap中的任务类型键。 */ + public static final String TASK_TYPE_KEY = "taskType"; + /** JobDataMap中的批量大小键。 */ + public static final String BATCH_SIZE_KEY = "batchSize"; + /** thirdparty内部任务分发 Feign 客户端,由 Quartz JobFactory 注入。 */ + @Autowired + private RemoteThirdPaymentTaskService paymentTaskService; + + /** + * 调用服务发现后的 thirdparty 内部接口;业务响应失败会让 Quartz 记录本次执行失败。 + * @param context Quartz触发上下文 + * @throws JobExecutionException thirdparty未接受或调用异常 + */ + @Override + public void execute(JobExecutionContext context) throws JobExecutionException { + String taskType = context.getMergedJobDataMap().getString(TASK_TYPE_KEY); + int batchSize = context.getMergedJobDataMap().getIntValue(BATCH_SIZE_KEY); + ThirdPaymentTaskDispatchDto dto = new ThirdPaymentTaskDispatchDto(); + dto.setTaskType(taskType); + dto.setBatchSize(batchSize); + dto.setTriggerSource("QUARTZ"); + dto.setTriggerTime(LocalDateTime.now()); + try { + R result = paymentTaskService.dispatch(dto); + if (result == null || !result.isOk() || result.getData() == null) { + throw new JobExecutionException("thirdparty未接受支付任务分发:" + taskType); + } + ThirdPaymentTaskDispatchVo data = result.getData(); + if (data.isBusy() || data.getBacklogCount() > 0) { + log.warn("支付任务分发存在积压,taskType:{},claimed:{},backlog:{},busy:{}", + taskType, data.getClaimedCount(), data.getBacklogCount(), data.isBusy()); + } + } catch (JobExecutionException exception) { + throw exception; + } catch (Exception exception) { + throw new JobExecutionException("调用thirdparty支付任务分发失败:" + taskType, exception, false); + } + } +} diff --git a/pjcloud-visual/pjcloud-daemon-quartz/src/main/resources/quartz-config.yml b/pjcloud-visual/pjcloud-daemon-quartz/src/main/resources/quartz-config.yml index 446f63153..6a25742bc 100644 --- a/pjcloud-visual/pjcloud-daemon-quartz/src/main/resources/quartz-config.yml +++ b/pjcloud-visual/pjcloud-daemon-quartz/src/main/resources/quartz-config.yml @@ -11,12 +11,14 @@ spring: class: org.springframework.scheduling.quartz.LocalDataSourceJobStore # 数据库存储 driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate # 数据库代理 tablePrefix: QRTZ_ # 表前缀 - isClustered: false # 集群 - clusterCheckinInterval: 30000 # 集群检查间隔 + isClustered: true # 支付任务只由Quartz集群触发,生产实例必须共享同一套QRTZ表 + clusterCheckinInterval: 10000 # 集群心跳,节点失联后由存活实例接管 + misfireThreshold: 60000 # 允许一分钟短暂抖动,恢复后按misfire策略继续触发 + acquireTriggersWithinLock: true # 多节点批量获取Trigger时在数据库锁内完成 useProperties: false threadPool: class: org.quartz.simpl.SimpleThreadPool # 线程池 - threadCount: 3 # 线程数量 + threadCount: 8 # 支付固定触发器与既有通用任务共享,外部支付调用不在Quartz线程执行 threadPriority: 5 # 线程优先级 threadsInheritContextClassLoaderOfInitializingThread: true # 是否继承类加载器 job-store-type: jdbc # 持久化到数据库 diff --git a/pjcloud-visual/pjcloud-daemon-quartz/src/test/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializerTest.java b/pjcloud-visual/pjcloud-daemon-quartz/src/test/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializerTest.java new file mode 100644 index 000000000..8eb8576f0 --- /dev/null +++ b/pjcloud-visual/pjcloud-daemon-quartz/src/test/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializerTest.java @@ -0,0 +1,59 @@ +package com.bsd.pjcloud.daemon.quartz.config; + +import com.bsd.pjcloud.daemon.quartz.job.ThirdPaymentDispatchJob; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum; +import org.junit.jupiter.api.Test; +import org.quartz.JobKey; +import org.quartz.Scheduler; +import org.quartz.SimpleTrigger; +import org.quartz.Trigger; +import org.quartz.TriggerKey; +import org.quartz.impl.StdSchedulerFactory; +import org.quartz.impl.matchers.GroupMatcher; + +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 支付固定 Quartz 触发器初始化测试,验证服务重复启动不会产生重复调度记录。 + */ +class ThirdPaymentQuartzInitializerTest { + + /** + * 使用内存 JobStore 连续初始化两次,确认 Job、Trigger、任务参数和固定频率均保持稳定。 + * + * @throws Exception Quartz 调度器创建、查询或关闭失败 + */ + @Test + void shouldRegisterManagedPaymentSchedulesIdempotently() throws Exception { + Properties properties = new Properties(); + properties.setProperty("org.quartz.scheduler.instanceName", "thirdPaymentInitializerTest"); + properties.setProperty("org.quartz.threadPool.threadCount", "1"); + properties.setProperty("org.quartz.jobStore.class", "org.quartz.simpl.RAMJobStore"); + Scheduler scheduler = new StdSchedulerFactory(properties).getScheduler(); + try { + ThirdPaymentQuartzInitializer initializer = new ThirdPaymentQuartzInitializer(scheduler); + initializer.afterPropertiesSet(); + initializer.afterPropertiesSet(); + + String group = "THIRD_PAYMENT_SYSTEM"; + assertThat(scheduler.getJobKeys(GroupMatcher.jobGroupEquals(group))).hasSize(15); + assertThat(scheduler.getTriggerKeys(GroupMatcher.triggerGroupEquals(group))).hasSize(15); + + JobKey jobKey = JobKey.jobKey("third-payment-pay-create", group); + assertThat(scheduler.getJobDetail(jobKey).getJobClass()).isEqualTo(ThirdPaymentDispatchJob.class); + assertThat(scheduler.getJobDetail(jobKey).getJobDataMap().getString(ThirdPaymentDispatchJob.TASK_TYPE_KEY)) + .isEqualTo(ThirdPaymentTaskTypeEnum.PAY_CREATE.getCode()); + assertThat(scheduler.getJobDetail(jobKey).getJobDataMap().getInt(ThirdPaymentDispatchJob.BATCH_SIZE_KEY)) + .isEqualTo(100); + + TriggerKey triggerKey = TriggerKey.triggerKey("third-payment-pay-create-trigger", group); + Trigger trigger = scheduler.getTrigger(triggerKey); + assertThat(trigger).isInstanceOf(SimpleTrigger.class); + assertThat(((SimpleTrigger) trigger).getRepeatInterval()).isEqualTo(5_000L); + } finally { + scheduler.shutdown(true); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java index b6c96de56..7d87b4e04 100755 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java @@ -57,9 +57,9 @@ public class MchPaySettingEntity extends Model { private String settingJson; /** - * 分账比例(最高100%即分给对方商户的%) + * 平台抽佣比例,单位为百分比,取值范围为大于0且不超过100。 */ - @Schema(description = "分账比例(最高100%即分给对方商户的%)") + @Schema(description = "平台抽佣比例,单位为百分比,取值范围为(0,100]") private Double fzRatio; /** @@ -117,14 +117,19 @@ public class MchPaySettingEntity extends Model { @Data static public class AliPaySetting { /** - * 子商户号 + * 支付宝直付通二级商户标识,保存支付宝分配的 smid/ip_role_id,不是平台应用商户号。 */ private String subMchid; /** - * 子商户授权token + * 支付宝第三方代调用授权令牌;无需代调用授权的账号允许为空。 */ private String appAuthToken; + + /** + * 平台支付宝命名账号键,来源于现有支付配置 JSON;为空时使用平台默认账号。 + */ + private String accountKey; } -} \ No newline at end of file +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteCodeUseService.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteCodeUseService.java index bc68fab31..bd7a92934 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteCodeUseService.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteCodeUseService.java @@ -37,6 +37,16 @@ public interface RemoteCodeUseService { @GetMapping("/wallet-code-use/cancel-code") R cancelCode(@RequestParam Long id); + /** + * 将预约订单二维码恢复为未核销状态,供订单核销纠错时再次使用。 + * + * @param id 预约订单业务主键 + * @return 找到预约二维码且已恢复或原本未使用时返回 {@code true} + */ + @NoToken + @GetMapping("/wallet-code-use/rollback-reservation-code") + R rollbackReservationCode(@RequestParam Long id); + /** * 二维码是否核销 * diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletCodeUseController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletCodeUseController.java index 2d209ba71..eb61a54d7 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletCodeUseController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletCodeUseController.java @@ -45,6 +45,18 @@ public class WalletCodeUseController { return R.ok(walletCodeUseService.cancelCode(id)); } + /** + * 恢复预约订单二维码的未核销状态,仅供订单服务处理后台核销纠错。 + * + * @param id 预约订单业务主键 + * @return 找到预约二维码且已恢复或原本未使用时返回 {@code true} + */ + @Inner + @GetMapping("/rollback-reservation-code") + public R rollbackReservationCode(@RequestParam Long id) { + return R.ok(walletCodeUseService.rollbackReservationCode(id)); + } + /** * 是否核销 */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchPaySettingController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchPaySettingController.java index 7f4d700f5..6847a8920 100755 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchPaySettingController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchPaySettingController.java @@ -68,7 +68,7 @@ public class MchPaySettingController { @GetMapping("/{id}") @PreAuthorize("@pms.hasPermission('wallet_mchPaySetting_view')") public R getById(@PathVariable("id") Long id) { - return R.ok(mchPaySettingService.getById(id)); + return R.ok(mchPaySettingService.getSafeById(id)); } /** diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchPaySettingService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchPaySettingService.java index c0fc49338..b332028a0 100755 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchPaySettingService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchPaySettingService.java @@ -12,12 +12,44 @@ import org.springframework.cache.annotation.CacheEvict; import java.util.List; +/** + * 商户支付配置服务,负责配置维护、数据权限查询以及客户端敏感信息脱敏。 + */ public interface MchPaySettingService extends IService { + /** + * 按数据权限分页查询可展示的支付配置,并移除客户端不需要的支付宝授权令牌。 + * + * @param page 分页参数 + * @param dto 支付配置筛选条件 + * @param dataScope 当前管理端数据权限 + * @return 已脱敏的支付配置分页 + */ Page scopePageList(Page page, MchPaySettingDto dto, DataScope dataScope); + /** + * 查询服务内部使用的完整支付配置,保留支付调用所需的授权令牌。 + * + * @param dto 支付配置筛选条件 + * @param dataScope 可选数据权限 + * @return 服务内部支付配置列表 + */ List pageList(MchPaySettingDto dto, DataScope dataScope); + /** + * 按主键获取管理端可编辑配置,支付宝授权令牌不会返回浏览器。 + * + * @param id 支付配置主键 + * @return 已脱敏的支付配置;不存在时返回 {@code null} + */ + MchPaySettingEntity getSafeById(Long id); + + /** + * 新增商户支付配置并通知第三方支付服务刷新商户快照。 + * + * @param dto 新增配置 + * @return 新增记录主键 + */ @CacheEvict( value = CacheConstants.WALLET_VIEWS_CANTEEN_MCH, allEntries = true, @@ -25,6 +57,12 @@ public interface MchPaySettingService extends IService { ) Long create(MchPaySettingDto dto); + /** + * 修改支付配置;支付宝授权令牌留空时保留已存值,返回结果不包含完整令牌。 + * + * @param dto 修改配置 + * @return 已脱敏的修改结果 + */ @CacheEvict( value = CacheConstants.WALLET_VIEWS_CANTEEN_MCH, condition = "#dto.getMchId() != null", @@ -34,7 +72,10 @@ public interface MchPaySettingService extends IService { MchPaySettingEntity edit(MchPaySettingDto dto); /** - * 用户可以支付的方式(目前微信、支付宝) + * 查询用户可用的微信、支付宝支付方式,返回客户端前移除支付宝授权令牌。 + * + * @param user 当前登录用户 + * @return 已脱敏的可用支付配置 */ List getUserPaySettingList(PjcloudUser user); -} \ No newline at end of file +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletCodeUseService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletCodeUseService.java index b5fb4d166..d7080fc04 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletCodeUseService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletCodeUseService.java @@ -27,6 +27,14 @@ public interface WalletCodeUseService extends IService { */ Boolean cancelCode(Long id); + /** + * 恢复预约订单二维码的未核销状态,不允许影响钱包码、活动码等其他业务二维码。 + * + * @param id 预约订单业务主键 + * @return 找到预约二维码且已恢复或原本未使用时返回 {@code true} + */ + Boolean rollbackReservationCode(Long id); + /** * 二维码是否核销 */ @@ -41,4 +49,4 @@ public interface WalletCodeUseService extends IService { * 根据短码查询加密信息 */ WalletCodeUseEntity getInfoByShortCode(String shortCode); -} \ No newline at end of file +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java index bc9ca188e..99bf9c561 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java @@ -98,6 +98,17 @@ public interface WalletTransactionService extends IService 0; } + /** + * 将预约订单对应的最新二维码恢复为未使用状态。业务类型白名单用于防止订单 ID 与其他二维码业务 ID + * 偶然相同后误恢复钱包码或活动码;条件更新用于和并发核销串行竞争,避免覆盖新的使用结果。 + * + * @param id 预约订单业务主键 + * @return 找到预约二维码且已恢复或原本未使用时返回 {@code true} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean rollbackReservationCode(Long id) { + WalletCodeUseEntity codeUse = baseMapper.selectOne(new LambdaQueryWrapper() + .eq(WalletCodeUseEntity::getBizId, id) + .in(WalletCodeUseEntity::getBizType, + WalletCodeBizEnum.CANTEEN_RESERVE.getCode(), WalletCodeBizEnum.SUPERMARKET_RESERVE.getCode()) + .orderByDesc(WalletCodeUseEntity::getCreateTime) + .orderByDesc(WalletCodeUseEntity::getId) + .last("limit 1")); + if (ObjectUtil.isEmpty(codeUse)) return false; + if (ObjectUtil.equals(codeUse.getStatus(), YesNoEnum.NO.getCode())) return true; + if (ObjectUtil.notEqual(codeUse.getStatus(), YesNoEnum.YES.getCode())) return false; + return baseMapper.update(null, new LambdaUpdateWrapper() + .set(WalletCodeUseEntity::getStatus, YesNoEnum.NO.getCode()) + .set(WalletCodeUseEntity::getUseTime, null) + .eq(WalletCodeUseEntity::getId, codeUse.getId()) + .eq(WalletCodeUseEntity::getStatus, YesNoEnum.YES.getCode())) == 1; + } + @Override @Transactional(rollbackFor = Exception.class) public Boolean isCancelCode(Long id) { @@ -283,4 +311,4 @@ public class WalletCodeUseServiceImpl extends ServiceImpl walletManageMapper.updateWhenRecharge(walletId, price); + case REFUND, TRANSFER_ACCOUNTS_REFUND, RETURN_CANTEEN -> walletManageMapper.updateWhenConsumptionRefund(walletId, price); + default -> throw new UnsupportedOperationException("待实现"); + } + WalletTransactionEntity transaction = buildWalletTransaction(wallet, walletId, price, + wallet.getBalance() + price, bizId, bizType); + save(transaction); + return transaction; + } + /** * 增加钱包余额并将来源业务的财务标签同步到最终流水,供后续列表筛选和金额统计。 * diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java index 8b65393b0..1d70f7ac5 100755 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java @@ -1,6 +1,8 @@ package com.bsd.pjcloud.wallet.service.impl.mch; +import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; @@ -11,6 +13,7 @@ import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppPlusPayService; import com.bsd.pjcloud.wallet.api.dto.mch.MchPaySettingDto; import com.bsd.pjcloud.wallet.api.entity.mch.MchManageEntity; @@ -49,7 +52,9 @@ public class MchPaySettingServiceImpl extends ServiceImpl scopePageList(Page page, MchPaySettingDto dto, DataScope dataScope) { - return baseMapper.scopePageList(page, dto, dataScope); + Page result = baseMapper.scopePageList(page, dto, dataScope); + result.setRecords(result.getRecords().stream().map(this::sanitizeForClient).toList()); + return result; } @Override @@ -57,6 +62,18 @@ public class MchPaySettingServiceImpl extends ServiceImpl { try { @@ -92,7 +110,7 @@ public class MchPaySettingServiceImpl extends ServiceImpl= 1; } -} \ No newline at end of file + /** + * 管理端因安全原因读取不到旧 token,提交空 token 时应保留数据库中的已配置值。 + * + * @param dto 管理端提交的支付配置 + */ + private void preserveAliPayAuthToken(MchPaySettingDto dto) { + if (dto == null || dto.getId() == null || !AppPlusPlatformPayType.ALI_PAY.getType().equals(dto.getPlatformType()) + || StrUtil.isBlank(dto.getSettingJson())) { + return; + } + MchPaySettingEntity existing = getById(dto.getId()); + if (existing == null || StrUtil.isBlank(existing.getSettingJson())) { + return; + } + try { + MchPaySettingEntity.AliPaySetting incoming = JSONUtil.toBean( + dto.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + MchPaySettingEntity.AliPaySetting stored = JSONUtil.toBean( + existing.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + if (incoming != null && stored != null && StrUtil.isBlank(incoming.getAppAuthToken()) + && StrUtil.isNotBlank(stored.getAppAuthToken())) { + incoming.setAppAuthToken(stored.getAppAuthToken()); + dto.setSettingJson(JSONUtil.toJsonStr(incoming)); + } + } catch (RuntimeException exception) { + throw new RuntimeException("支付宝支付配置无效"); + } + } + + /** + * 复制并脱敏支付配置,保证调用方不能通过返回对象修改缓存或内部商户配置。 + * + * @param source 内部完整支付配置 + * @param 支付配置实体或视图类型 + * @return 同类型的脱敏副本 + */ + @SuppressWarnings("unchecked") + private T sanitizeForClient(T source) { + if (source == null) { + return null; + } + T target = (T) BeanUtil.toBean(source, source.getClass()); + if (!AppPlusPlatformPayType.ALI_PAY.getType().equals(target.getPlatformType()) || StrUtil.isBlank(target.getSettingJson())) { + return target; + } + try { + MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean( + target.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + if (aliPaySetting != null) { + aliPaySetting.setAppAuthToken(null); + target.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); + } + } catch (RuntimeException exception) { + // 无法解析的历史配置不应原样下发,以免其中包含未识别的敏感字段。 + target.setSettingJson(null); + } + return target; + } + +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargePackageServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargePackageServiceImpl.java index 8022f66c5..ed1292ab6 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargePackageServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargePackageServiceImpl.java @@ -90,8 +90,9 @@ public class WalletRechargePackageServiceImpl extends ServiceImpl { + WalletTransactionEntity transaction = invocation.getArgument(0); + transaction.setId(1001L); + transaction.setTenantId(1L); + return 1; + }); + when(walletManageMapper.selectById(20L)).thenReturn(wallet); + when(walletMessageOutboxMapper.insert(any(WalletMessageOutboxEntity.class))).thenReturn(1); + + WalletTransactionEntity result = service.addWalletBalanceIdempotent(20L, 50, "pay-1001", WalletBizEnum.RECHARGE); + + assertEquals(50, result.getPrice()); + assertEquals(150, result.getBalance()); + assertEquals("pay-1001", result.getBizId()); + verify(walletManageMapper).updateWhenRecharge(20L, 50); + verify(walletTransactionMapper).insert(any(WalletTransactionEntity.class)); + verify(walletMessageOutboxMapper).insert(any(WalletMessageOutboxEntity.class)); + } + + /** + * 已存在金额一致的充值流水应直接返回,不再次修改钱包余额。 + */ + @Test + void repeatedIdempotentCreditShouldReturnExistingTransaction() { + WalletManageEntity wallet = wallet(20L, 1L); + wallet.setBalance(150); + WalletTransactionEntity existing = new WalletTransactionEntity(); + existing.setWalletId(20L); + existing.setPrice(50); + existing.setBizId("pay-1001"); + existing.setBizType(WalletBizEnum.RECHARGE.getCode()); + when(walletManageMapper.selectByIdForUpdate(20L)).thenReturn(wallet); + when(walletTransactionMapper.selectOne(any(Wrapper.class))).thenReturn(existing); + + assertSame(existing, service.addWalletBalanceIdempotent(20L, 50, "pay-1001", WalletBizEnum.RECHARGE)); + + verify(walletManageMapper, never()).updateWhenRecharge(any(), any()); + verify(walletTransactionMapper, never()).insert(any(WalletTransactionEntity.class)); + } + /** * 构造指定所有人和 ID 的 EAT 饭卡。 * diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java new file mode 100644 index 000000000..11e14279e --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java @@ -0,0 +1,123 @@ +package com.bsd.pjcloud.wallet.service.impl.mch; + +import cn.hutool.json.JSONUtil; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppPlusPayService; +import com.bsd.pjcloud.wallet.api.dto.mch.MchPaySettingDto; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.bsd.pjcloud.wallet.mapper.mch.MchPaySettingMapper; +import com.bsd.pjcloud.wallet.service.MchManageService; +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.test.util.ReflectionTestUtils; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 商户支付配置支付宝授权令牌脱敏和更新保留测试。 + */ +@ExtendWith(MockitoExtension.class) +class MchPaySettingServiceImplTest { + + /** 支付配置 Mapper Mock,提供数据库中的完整配置。 */ + @Mock + private MchPaySettingMapper mchPaySettingMapper; + + /** 商户服务 Mock,仅补齐异步刷新依赖。 */ + @Mock + private MchManageService mchManageService; + + /** 用户远程服务 Mock,仅补齐构造依赖。 */ + @Mock + private RemoteUserService remoteUserService; + + /** 第三方支付远程服务 Mock,仅补齐异步刷新依赖。 */ + @Mock + private RemoterThirdAppPlusPayService thirdAppPlusPayService; + + /** 被测支付配置服务。 */ + @InjectMocks + private MchPaySettingServiceImpl service; + + /** + * 将 MyBatis-Plus 基类 Mapper 指向当前 Mock。 + */ + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "baseMapper", mchPaySettingMapper); + } + + /** + * 验证管理端详情保留 smid 和账号键,但不返回完整授权令牌。 + */ + @Test + void getSafeByIdShouldRemoveAliPayAuthToken() { + MchPaySettingEntity stored = storedAliPaySetting(); + when(mchPaySettingMapper.selectById(stored.getId())).thenReturn(stored); + + MchPaySettingEntity result = service.getSafeById(stored.getId()); + + MchPaySettingEntity.AliPaySetting safe = JSONUtil.toBean( + result.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + Assertions.assertEquals("smid-1001", safe.getSubMchid()); + Assertions.assertEquals("secondary", safe.getAccountKey()); + Assertions.assertNull(safe.getAppAuthToken()); + Assertions.assertTrue(stored.getSettingJson().contains("sensitive-auth-token")); + } + + /** + * 验证前端提交空 token 时保存旧值,但接口返回值仍保持脱敏。 + */ + @Test + void editShouldPreserveStoredAliPayAuthTokenWithoutReturningIt() { + MchPaySettingEntity stored = storedAliPaySetting(); + when(mchPaySettingMapper.selectCount(any())).thenReturn(1L); + when(mchPaySettingMapper.selectById(stored.getId())).thenReturn(stored); + MchPaySettingEntity.AliPaySetting incomingSetting = new MchPaySettingEntity.AliPaySetting(); + incomingSetting.setSubMchid("smid-1001"); + incomingSetting.setAccountKey("secondary"); + incomingSetting.setAppAuthToken(""); + MchPaySettingDto incoming = new MchPaySettingDto(); + incoming.setId(stored.getId()); + incoming.setMchId(stored.getMchId()); + incoming.setPlatformType("20"); + incoming.setSettingJson(JSONUtil.toJsonStr(incomingSetting)); + + MchPaySettingEntity result = service.edit(incoming); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(MchPaySettingEntity.class); + verify(mchPaySettingMapper).updateById(updateCaptor.capture()); + MchPaySettingEntity.AliPaySetting persisted = JSONUtil.toBean( + updateCaptor.getValue().getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + Assertions.assertEquals("sensitive-auth-token", persisted.getAppAuthToken()); + MchPaySettingEntity.AliPaySetting safeResult = JSONUtil.toBean( + result.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + Assertions.assertNull(safeResult.getAppAuthToken()); + } + + /** + * 构造数据库中带完整支付宝授权令牌的支付配置。 + * + * @return 完整内部配置 + */ + private MchPaySettingEntity storedAliPaySetting() { + MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting(); + aliPaySetting.setSubMchid("smid-1001"); + aliPaySetting.setAccountKey("secondary"); + aliPaySetting.setAppAuthToken("sensitive-auth-token"); + MchPaySettingEntity stored = new MchPaySettingEntity(); + stored.setId(1L); + stored.setMchId(88L); + stored.setPlatformType("20"); + stored.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); + return stored; + } +}