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 extends Object> 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("" + element + ">", 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;
+ }
+}