diff --git a/db/20260824_add_third_payment_task.sql b/db/20260824_add_third_payment_task.sql
index 7fc7ded9..31468234 100644
--- a/db/20260824_add_third_payment_task.sql
+++ b/db/20260824_add_third_payment_task.sql
@@ -31,8 +31,8 @@ CREATE TABLE IF NOT EXISTS `third_payment_task`
`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_due` (`task_type`, `status`, `next_run_time`, `priority`, `id`),
+ KEY `idx_third_payment_task_lease` (`task_type`, `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 = '第三方支付任务事实';
@@ -57,3 +57,97 @@ CREATE TABLE IF NOT EXISTS `third_payment_task_attempt`
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 = '第三方支付任务执行尝试';
+
+-- Quartz 会按业务事实持续物化任务。百万级订单量下,如果业务表只有主键,每个固定触发器都会退化为全表扫描。
+-- 以下索引只覆盖实际扫描谓词和租户内业务号关联;使用 information_schema 判断保证迁移可以重复执行。
+SET @index_ddl = IF(
+ EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_task_scan'),
+ 'SELECT ''idx_third_pay_order_task_scan already exists'' AS message',
+ 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_task_scan` (`status`, `del_flag`, `pay_platform_type`, `update_time`, `id`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+SET @index_ddl = IF(
+ EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_fz_scan'),
+ 'SELECT ''idx_third_pay_order_fz_scan already exists'' AS message',
+ 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_fz_scan` (`status`, `enable_fz`, `del_flag`, `pay_time`, `id`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+SET @index_ddl = IF(
+ EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_callback_scan'),
+ 'SELECT ''idx_third_pay_order_callback_scan already exists'' AS message',
+ 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_callback_scan` (`status`, `is_biz_callback`, `del_flag`, `update_time`, `id`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+SET @index_ddl = IF(
+ EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_close_scan'),
+ 'SELECT ''idx_third_pay_order_close_scan already exists'' AS message',
+ 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_close_scan` (`status`, `biz_type`, `del_flag`, `create_time`, `id`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+SET @index_ddl = IF(
+ EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_tenant_pay_no'),
+ 'SELECT ''idx_third_pay_order_tenant_pay_no already exists'' AS message',
+ 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+SET @index_ddl = IF(
+ EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'third_fz_order' AND index_name = 'idx_third_fz_order_task_scan'),
+ 'SELECT ''idx_third_fz_order_task_scan already exists'' AS message',
+ 'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_task_scan` (`status`, `del_flag`, `update_time`, `id`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+SET @index_ddl = IF(
+ EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'third_fz_order' AND index_name = 'idx_third_fz_order_tenant_pay_no'),
+ 'SELECT ''idx_third_fz_order_tenant_pay_no already exists'' AS message',
+ 'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+SET @index_ddl = IF(
+ EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'third_aggregate_pay_session' AND index_name = 'idx_third_aggregate_task_scan'),
+ 'SELECT ''idx_third_aggregate_task_scan already exists'' AS message',
+ 'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_task_scan` (`status`, `del_flag`, `update_time`, `id`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+SET @index_ddl = IF(
+ EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'third_aggregate_pay_session' AND index_name = 'idx_third_aggregate_close_scan'),
+ 'SELECT ''idx_third_aggregate_close_scan already exists'' AS message',
+ 'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_close_scan` (`status`, `del_flag`, `create_time`, `id`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+-- 生产执行建议:低峰期使用在线 DDL 能力建索引,完成后 ANALYZE TABLE 并用真实状态分布执行 EXPLAIN ANALYZE。
diff --git a/db/20260825_add_third_mch_fz_relation.sql b/db/20260825_add_third_mch_fz_relation.sql
new file mode 100644
index 00000000..25115867
--- /dev/null
+++ b/db/20260825_add_third_mch_fz_relation.sql
@@ -0,0 +1,79 @@
+USE `pjcloudx_thirdparty`;
+
+-- 商户默认平台分佣关系。微信关系由 sub_mchid 区分,支付宝关系由调用应用、授权商户和接收账号共同确定。
+-- 表中只保存渠道标识和脱敏调用快照,不保存私钥、公钥或完整 app_auth_token。
+CREATE TABLE IF NOT EXISTS `third_mch_fz_relation`
+(
+ `id` bigint NOT NULL COMMENT '关系主键,应用侧雪花算法生成',
+ `tenant_id` bigint NOT NULL COMMENT '租户ID,与 mch_manage 所属租户一致',
+ `mch_id` bigint NOT NULL COMMENT 'mch_manage 商户主键',
+ `pay_setting_id` bigint NOT NULL COMMENT 'mch_pay_setting 支付配置主键',
+ `platform_type` varchar(32) NOT NULL COMMENT '支付平台类型:10微信、20支付宝',
+ `relation_scene` varchar(64) NOT NULL COMMENT '本地关系场景,默认平台分佣为 DEFAULT_PLATFORM_COMMISSION',
+ `channel_app_id` varchar(64) NOT NULL COMMENT '微信服务商AppID或支付宝平台AppID快照',
+ `channel_sub_mch_id` varchar(64) NOT NULL COMMENT '微信子商户号或支付宝直付通smid/ip_role_id',
+ `account_key` varchar(64) DEFAULT NULL COMMENT '支付宝命名平台账号键;默认账号及微信为空',
+ `receiver_type` varchar(32) NOT NULL COMMENT '渠道接收方类型,如MERCHANT_ID、userId、loginName',
+ `receiver_account` varchar(128) NOT NULL COMMENT '平台分佣接收账号;管理端展示时必须脱敏',
+ `receiver_name` varchar(128) DEFAULT NULL COMMENT '平台分佣接收方名称快照',
+ `channel_relation_type` varchar(64) DEFAULT NULL COMMENT '渠道关系类型;微信默认SERVICE_PROVIDER,支付宝为空',
+ `out_request_no` varchar(64) NOT NULL COMMENT '支付宝绑定稳定幂等请求号;微信作为本地稳定审计号',
+ `status` varchar(32) NOT NULL COMMENT 'PENDING_BIND、BINDING、BOUND、BIND_FAILED或SUPERSEDED',
+ `is_default` char(1) NOT NULL DEFAULT '1' COMMENT '是否为当前支付配置的默认平台分佣关系:0否、1是',
+ `bind_req` text DEFAULT NULL COMMENT '脱敏后的关系绑定请求快照',
+ `bind_res` text DEFAULT NULL COMMENT '渠道关系绑定或查询响应快照',
+ `bind_error` varchar(1000) DEFAULT NULL COMMENT '最近一次绑定错误摘要,不含凭据',
+ `last_external_status` varchar(128) DEFAULT NULL COMMENT '最近一次渠道结果码或外部状态',
+ `last_query_time` datetime DEFAULT NULL COMMENT '最近一次关系绑定或查询时间',
+ `bound_time` datetime DEFAULT NULL COMMENT '渠道明确确认关系已绑定的时间',
+ `create_by` varchar(32) DEFAULT NULL COMMENT '创建人',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` varchar(32) DEFAULT NULL COMMENT '更新人',
+ `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+ `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记:0正常、1删除',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_third_mch_fz_relation_identity`
+ (`tenant_id`, `pay_setting_id`, `platform_type`, `channel_app_id`, `channel_sub_mch_id`,
+ `receiver_type`, `receiver_account`, `relation_scene`),
+ KEY `idx_third_mch_fz_relation_default`
+ (`tenant_id`, `mch_id`, `platform_type`, `is_default`, `status`, `del_flag`),
+ KEY `idx_third_mch_fz_relation_task`
+ (`status`, `is_default`, `update_time`, `id`)
+) ENGINE = InnoDB
+ DEFAULT CHARSET = utf8mb4
+ COLLATE = utf8mb4_general_ci
+ COMMENT ='商户第三方支付分账关系';
+
+-- 支付任务通过结构化主键定位关系事实,禁止从 task_key 字符串反解析业务主键。
+SET @relation_task_column_exists = (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'third_payment_task'
+ AND COLUMN_NAME = 'fz_relation_id'
+);
+SET @relation_task_column_sql = IF(
+ @relation_task_column_exists = 0,
+ 'ALTER TABLE `third_payment_task` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''商户分账关系主键'' AFTER `fz_order_id`, ADD INDEX `idx_third_payment_task_relation` (`tenant_id`, `fz_relation_id`)',
+ 'SELECT 1'
+);
+PREPARE relation_task_column_stmt FROM @relation_task_column_sql;
+EXECUTE relation_task_column_stmt;
+DEALLOCATE PREPARE relation_task_column_stmt;
+
+-- 支付订单固定引用创建时使用的已绑定关系,后续分账不跟随平台接收账号配置漂移。
+SET @pay_relation_column_exists = (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'third_pay_order'
+ AND COLUMN_NAME = 'fz_relation_id'
+);
+SET @pay_relation_column_sql = IF(
+ @pay_relation_column_exists = 0,
+ 'ALTER TABLE `third_pay_order` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''支付创建时绑定的平台分佣关系主键'' AFTER `mch_pay_setting_json`, ADD INDEX `idx_third_pay_order_fz_relation` (`tenant_id`, `fz_relation_id`)',
+ 'SELECT 1'
+);
+PREPARE pay_relation_column_stmt FROM @pay_relation_column_sql;
+EXECUTE pay_relation_column_stmt;
+DEALLOCATE PREPARE pay_relation_column_stmt;
diff --git a/db/20260825_add_wallet_transaction_remark.sql b/db/20260825_add_wallet_transaction_remark.sql
new file mode 100644
index 00000000..ee5f5b73
--- /dev/null
+++ b/db/20260825_add_wallet_transaction_remark.sql
@@ -0,0 +1,3 @@
+-- 为每笔钱包流水增加可选业务备注;历史流水保持为空,前后端统一限制为 256 个字符。
+ALTER TABLE wallet_transaction
+ ADD COLUMN remark varchar(256) DEFAULT NULL COMMENT '流水备注,最多256个字符' AFTER tags;
diff --git a/db/800pjcloudx_wallet.sql b/db/800pjcloudx_wallet.sql
index c4583ce2..462e9448 100644
--- a/db/800pjcloudx_wallet.sql
+++ b/db/800pjcloudx_wallet.sql
@@ -456,6 +456,7 @@ CREATE TABLE wallet_transaction
price int NOT NULL COMMENT '交易金额, 单位分',
balance int NOT NULL COMMENT '余额, 单位分',
tags varchar(512) DEFAULT NULL COMMENT '财务统计标签,多个wallet_transaction_tag字典值使用英文逗号分隔',
+ remark varchar(256) DEFAULT NULL COMMENT '流水备注,最多256个字符',
create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人',
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 0a2829e9..a25062a1 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
@@ -56,6 +56,11 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
*/
private List saleSourceCodes;
+ /**
+ * 商品名称模糊查询条件,仅供购物车商品统计 SQL 筛选使用,不对应订单表持久化字段。
+ */
+ private String productName;
+
private LocalDateTime[] refundTimeList;
/** 预计使用日期范围。 */
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java
index cfb4aeb2..96f057a1 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java
@@ -3,23 +3,27 @@ package com.bsd.pjcloud.hard.api.vo.order;
import lombok.Data;
/**
- * 食堂预约菜品导出聚合记录。
+ * 食堂菜品销售三级导出的聚合记录。
*
- * 同一模型承载商户部门聚合和商品聚合两种查询结果,商品聚合时额外填充商品字段;金额统一使用分。
+ * 同一模型承载日期餐段、商户部门和商品三种聚合查询结果,商品聚合时额外填充商品字段;
+ * 统计时间已按销售来源统一转换为业务发生日期,金额统一使用分。
*/
@Data
public class HardCanteenProductApptExportRecord {
- /** 预约使用日期,格式为 yyyy-MM-dd。 */
- private String apptDate;
+ /** 业务发生日期,格式为 yyyy-MM-dd;线下取支付日期,预约取核销日期。 */
+ private String statisticsDate;
/** 用餐时间段协议编码。 */
private String hourType;
+ /** 销售来源协议编码;为空表示当前筛选范围内的跨来源合计。 */
+ private String saleSource;
+
/** 食堂商户主键。 */
private Long merchantId;
- /** 预约用户所属部门主键。 */
+ /** 购买用户所属部门主键。 */
private Long userDeptId;
/** 商品主键;商户部门聚合记录为空。 */
@@ -28,15 +32,15 @@ public class HardCanteenProductApptExportRecord {
/** 订单快照中的商品名称;商户部门聚合记录为空。 */
private String productName;
- /** 去重预约人数。 */
+ /** 当前统计层级内去重后的交易人数。 */
private Long payUserCount;
- /** 去重预约次数。 */
+ /** 当前统计层级内去重后的订单数量。 */
private Long timeCount;
- /** 预约商品总数量。 */
+ /** 有效商品总数量,预约来源已扣减退款完成数量。 */
private Long payCount;
- /** 预约商品总价,单位为分。 */
+ /** 有效商品总价,单位为分;预约来源已扣减退款完成金额。 */
private Long totalPrice;
}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java
index bdf91045..f83e31a4 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java
@@ -9,32 +9,36 @@ import java.math.BigDecimal;
import java.time.LocalDate;
/**
- * 食堂预约菜品统计自定义导出行。
+ * 食堂菜品销售统计自定义导出行。
*
- * 一组日期和餐段按汇总行、商户部门行、商品明细行组织;商品统计在同一商户部门块内按名称合并套餐展开后的明细,
- * 不同层级只填写所属列,配合自定义合并处理器形成树形报表。
+ * 一个日期、餐段和来源口径按汇总行、商户部门行、商品明细行组织;来源口径包含筛选范围合计及各销售来源。
+ * 商品统计在同一商户部门块内按名称合并套餐展开后的明细,不同层级只填写所属列,配合自定义合并处理器形成树形报表。
*/
@Data
public class HardCanteenProductApptExcelVo {
- /** 日期餐段汇总中的预约日期。 */
- @ExcelProperty({"日期餐段汇总", "预约时间"})
+ /** 日期餐段汇总中的业务发生日期。 */
+ @ExcelProperty({"日期餐段汇总", "统计日期"})
private LocalDate summaryDate;
/** 日期餐段汇总中的餐段编码或字典名称。 */
- @ExcelProperty({"日期餐段汇总", "预约时间段"})
+ @ExcelProperty({"日期餐段汇总", "餐段"})
private String summaryHourType;
- /** 日期餐段汇总中的去重预约人数。 */
- @ExcelProperty({"日期餐段汇总", "预约人数"})
+ /** 日期餐段汇总的来源口径,取值为筛选范围合计、线下购买或预约核销。 */
+ @ExcelProperty({"日期餐段汇总", "来源口径"})
+ private String summarySaleSourceName;
+
+ /** 日期餐段汇总中的去重交易人数。 */
+ @ExcelProperty({"日期餐段汇总", "交易人数"})
private Long summaryPayUserCount;
- /** 日期餐段汇总中的预约次数。 */
- @ExcelProperty({"日期餐段汇总", "预约次数"})
+ /** 日期餐段汇总中的交易次数。 */
+ @ExcelProperty({"日期餐段汇总", "交易次数"})
private Long summaryTimeCount;
/** 日期餐段汇总中的商品总数量。 */
- @ExcelProperty({"日期餐段汇总", "预约商品数量"})
+ @ExcelProperty({"日期餐段汇总", "商品数量"})
private Long summaryPayCount;
/** 日期餐段汇总中的总价,单位为元。 */
@@ -50,24 +54,24 @@ public class HardCanteenProductApptExcelVo {
@ExcelProperty({"商户部门统计", "部门"})
private String departmentName;
- /** 商户部门统计中的预约日期。 */
- @ExcelProperty({"商户部门统计", "预约时间"})
+ /** 商户部门统计中的业务发生日期。 */
+ @ExcelProperty({"商户部门统计", "统计日期"})
private LocalDate merchantDate;
/** 商户部门统计中的餐段名称。 */
- @ExcelProperty({"商户部门统计", "预约时间段"})
+ @ExcelProperty({"商户部门统计", "餐段"})
private String merchantHourType;
- /** 商户部门统计中的去重预约人数。 */
- @ExcelProperty({"商户部门统计", "预约人数"})
+ /** 商户部门统计中的去重交易人数。 */
+ @ExcelProperty({"商户部门统计", "交易人数"})
private Long merchantPayUserCount;
- /** 商户部门统计中的预约次数。 */
- @ExcelProperty({"商户部门统计", "预约次数"})
+ /** 商户部门统计中的交易次数。 */
+ @ExcelProperty({"商户部门统计", "交易次数"})
private Long merchantTimeCount;
- /** 商户部门统计中的商品总数量。 */
- @ExcelProperty({"商户部门统计", "预约商品数量"})
+ /** 商户部门统计中的有效商品总数量。 */
+ @ExcelProperty({"商户部门统计", "商品数量"})
private Long merchantPayCount;
/** 商户部门统计中的总价,单位为元。 */
@@ -92,7 +96,7 @@ public class HardCanteenProductApptExcelVo {
@ExcelProperty({"商品统计", "商品名称"})
private String statisticProductName;
- /** 商品统计中的商品总数量,按退款扣减及套餐展开后的有效明细累计。 */
+ /** 商品统计中的商品总数量,预约按退款扣减,套餐按组成商品展开后的有效明细累计。 */
@ExcelProperty({"商品统计", "商品总数量"})
private Long statisticProductCount;
@@ -101,7 +105,7 @@ public class HardCanteenProductApptExcelVo {
@NumberFormat("#,##0.00")
private BigDecimal statisticProductTotalPrice;
- /** 导出行层级,仅供合并处理器识别,不写入 Excel。 */
+ /** 导出行层级,仅供合并处理器和测试识别,不写入 Excel。 */
@ExcelIgnore
private String rowLevel;
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java
index b8c95bab..fa9811af 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java
@@ -21,25 +21,25 @@ import java.util.HashMap;
import java.util.Map;
/**
- * 食堂预约菜品统计导出合并处理器。
+ * 食堂菜品销售统计三级导出合并处理器。
*
- * 数据行通过各层级专属列识别:日期餐段行填写第 0 列,商户部门行填写第 6 列,商品行填写第 14 列;
- * 第 17 至 19 列展示同一商户部门内按名称合并后的商品统计。
+ *
数据行通过各层级专属列识别:日期餐段来源行填写第 0 列,商户部门行填写第 7 列,商品行填写第 15 列;
+ * 第 18 至 20 列展示同一商户部门内按名称合并后的商品统计。
* Easy Excel 使用流式工作簿,较早的数据行会在关闭工作簿前刷出内存,因此必须在逐行写出时记录分组边界并及时合并。
*/
public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler, WorkbookWriteHandler {
/** 日期餐段汇总列。 */
- private static final int[] SUMMARY_COLUMNS = {0, 1, 2, 3, 4, 5};
+ private static final int[] SUMMARY_COLUMNS = {0, 1, 2, 3, 4, 5, 6};
/** 商户部门统计列。 */
- private static final int[] MERCHANT_COLUMNS = {6, 7, 8, 9, 10, 11, 12, 13};
+ private static final int[] MERCHANT_COLUMNS = {7, 8, 9, 10, 11, 12, 13, 14};
/** Easy Excel 双层表头占用两行,数据从第三行开始。 */
private static final int DATA_START_ROW = 2;
/** 导出列总数,用于逐行应用居中样式。 */
- private static final int COLUMN_COUNT = 20;
+ private static final int COLUMN_COUNT = 21;
/** 表头字体大小,单位为磅;使用紧凑字号降低双层表头占用空间。 */
private static final short HEAD_FONT_SIZE = 10;
@@ -57,7 +57,7 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler
* 各列宽度,单位为 Excel 字符宽度;常规统计列保持紧凑,商品名称列单独加宽以展示更多名称内容。
*/
private static final int[] COLUMN_WIDTHS = {
- 12, 11, 10, 10, 14, 11, 14, 14, 12, 11, 10, 10, 14, 11, 32, 9, 11, 32, 14, 12
+ 12, 11, 16, 10, 10, 14, 11, 14, 14, 12, 11, 10, 10, 14, 11, 32, 9, 11, 32, 14, 12
};
/** 原样式索引到居中样式的缓存,避免每个数据单元格重复创建样式。 */
@@ -113,7 +113,7 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler
applyCenteredStyle(row, writeSheetHolder.getParentWriteWorkbookHolder());
int rowIndex = row.getRowNum();
boolean summaryRow = hasValue(row, 0);
- boolean merchantRow = hasValue(row, 6);
+ boolean merchantRow = hasValue(row, 7);
boolean newDateGroup = false;
if (summaryRow) {
String summaryDateKey = getCellValueKey(row, 0);
@@ -260,7 +260,7 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler
CellStyle copiedStyle = holder.getWorkbook().createCellStyle();
copiedStyle.cloneStyleFrom(originalStyle);
if (!(copiedStyle instanceof XSSFCellStyle xssfStyle)) {
- throw new IllegalStateException("菜品预约导出仅支持 XLSX 彩色边框样式");
+ throw new IllegalStateException("菜品销售统计导出仅支持 XLSX 彩色边框样式");
}
XSSFColor color = new XSSFColor(DATE_GROUP_BORDER_COLORS[colorIndex]);
if (leftBorder) {
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 6fcabb89..ef4e2303 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
@@ -69,14 +69,29 @@ public interface HardCarMapper extends PjcloudBaseMapper {
Page productTotalByApptUseDate(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
- /** 按预约日期和餐段查询导出所需的日期餐段总计。 */
- List listApptExportSummary(@Param("dto") HardOrderDto dto);
+ /**
+ * 查询菜品销售三级导出的日期餐段汇总及来源拆分。
+ *
+ * @param dto 统计日期、来源、餐段、食堂和部门筛选条件
+ * @return 每个日期餐段的跨来源合计及各来源统计,交易人数在当前层级独立去重
+ */
+ List listSalesExportSummary(@Param("dto") HardOrderDto dto);
- /** 按预约日期、餐段、商户和部门查询导出商户部门统计。 */
- List listApptExportMerchant(@Param("dto") HardOrderDto dto);
+ /**
+ * 查询菜品销售三级导出的商户部门汇总及来源拆分。
+ *
+ * @param dto 统计日期、来源、餐段、食堂和部门筛选条件
+ * @return 每个日期餐段下商户部门的跨来源合计及各来源统计
+ */
+ List listSalesExportMerchant(@Param("dto") HardOrderDto dto);
- /** 按预约日期、餐段、商户、部门和商品查询导出商品明细。 */
- List listApptExportProduct(@Param("dto") HardOrderDto dto);
+ /**
+ * 查询菜品销售三级导出的商品汇总及来源拆分。
+ *
+ * @param dto 统计日期、来源、餐段、食堂和部门筛选条件
+ * @return 每个商户部门块下商品的跨来源合计及各来源统计
+ */
+ List listSalesExportProduct(@Param("dto") HardOrderDto dto);
Page proudctQuery(Page page, @Param("dto") HardCarDto dto);
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 45518495..7502b74e 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,8 +15,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.HardCanteenDeptTotalExcelVo;
-import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductTotalExcelVo;
+import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
import java.util.List;
import java.util.Map;
@@ -69,21 +68,14 @@ 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 dto 统计日期、来源、餐段、食堂和部门筛选条件
* @param dataScope 部门数据权限;为空时沿用控制器既有范围
- * @return 按日期、食堂、来源、部门和餐段拆分的导出行
+ * @return 日期餐段、商户部门、商品三级排列的导出行
*/
- List exportDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope);
+ List exportSales(HardOrderDto dto, DataScope dataScope);
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java
index 9fc2cc23..b8656b81 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java
@@ -3,6 +3,7 @@ package com.bsd.pjcloud.hard.service.impl.order;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.entity.SysDictItem;
@@ -10,7 +11,10 @@ import com.bsd.pjcloud.admin.api.feign.RemoteDataScopeService;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteDictService;
import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenCategoryService;
+import com.bsd.pjcloud.canteen.api.feign.RemoteDailyMenuService;
import com.bsd.pjcloud.canteen.entity.CanteenCategoryEntity;
+import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO;
+import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.CollectionUtils;
@@ -24,14 +28,14 @@ import com.bsd.pjcloud.hard.api.dto.order.HardCarDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppProductByCanteenDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppProductByClassifyDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppProductLikeTopDto;
+import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
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 com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
import com.bsd.pjcloud.hard.mapper.order.HardCarMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardCanteenTotalService;
@@ -50,11 +54,14 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
@@ -73,6 +80,9 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
static private final String TAG = "食堂统计";
+ /** 当前用户没有可见食堂时使用的不可命中主键,防止空集合被解释为不限制商户。 */
+ private static final Long NO_VISIBLE_CANTEEN_ID = Long.MIN_VALUE;
+
private final HardOrderMapper hardOrderMapper;
private final HardCarMapper hardCarMapper;
@@ -81,6 +91,9 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
private final RemoteDataScopeService dataScopeService;
private final RemoteCanteenCategoryService canteenCategoryService;
+ /** 每日菜谱远程服务,用于在三级导出中按业务日期展开套餐组成商品。 */
+ private final RemoteDailyMenuService remoteDailyMenuService;
+
/** 餐段字典远程服务,仅在生成统计导出文案时读取 daily_menu。 */
private final RemoteDictService remoteDictService;
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 ed7348bd..1237b604 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
@@ -640,138 +640,164 @@
order by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d') desc, o.hour_type
-
-
- select DATE_FORMAT(o.appt_use_date, '%Y-%m-%d') as createTime,
- o.hour_type as hourType,
- SUM(a.product_count) as payCount,
- SUM(a.total_price) as totalPrice,
- count(distinct o.id) as timeCount,
- count(distinct o.user_id) as payUserCount
- 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.hour_type is not null and o.hour_type not in ('-1')
-
-
- and o.order_type = #{dto.orderType}
- and (o.appt_id is not null or o.appt_rule_id is not null)
-
- and o.order_status in
- #{v}
-
- and o.hour_type = #{dto.hourType}
- and o.merchant_id = #{dto.merchantId}
-
-
- and o.user_dept_id not in
- and o.user_dept_id in
-
- #{v}
-
-
- and o.merchant_dept_id in
- #{v}
-
-
- and o.merchant_id in
- #{v}
-
-
- group by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d'), o.hour_type
- order by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d'), o.hour_type
+
+
+ with sale_stats as (
+ select * from
+ )
+ select export_rows.statisticsDate,
+ export_rows.hourType,
+ export_rows.saleSource,
+ export_rows.payUserCount,
+ export_rows.timeCount,
+ export_rows.payCount,
+ export_rows.totalPrice
+ from (
+ select sale_stats.stat_date as statisticsDate,
+ sale_stats.hour_type as hourType,
+ null as saleSource,
+ count(distinct sale_stats.user_id) as payUserCount,
+ count(distinct sale_stats.order_id) as timeCount,
+ sum(sale_stats.product_count) as payCount,
+ sum(sale_stats.total_price) as totalPrice
+ from sale_stats
+
+
+
+ group by sale_stats.stat_date, sale_stats.hour_type
+ union all
+ select sale_stats.stat_date as statisticsDate,
+ sale_stats.hour_type as hourType,
+ sale_stats.sale_source as saleSource,
+ count(distinct sale_stats.user_id) as payUserCount,
+ count(distinct sale_stats.order_id) as timeCount,
+ sum(sale_stats.product_count) as payCount,
+ sum(sale_stats.total_price) as totalPrice
+ from sale_stats
+
+
+
+ group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.sale_source
+ ) export_rows
+ order by export_rows.statisticsDate,
+ export_rows.hourType,
+ case when export_rows.saleSource is null then 0
+ when export_rows.saleSource = 'OFFLINE_PURCHASE' then 1 else 2 end
-
-
- select DATE_FORMAT(o.appt_use_date, '%Y-%m-%d') as apptDate,
- o.hour_type as hourType,
- o.merchant_id as merchantId,
- o.user_dept_id as userDeptId,
- count(distinct o.user_id) as payUserCount,
- count(distinct o.id) as timeCount,
- SUM(a.product_count) as payCount,
- SUM(a.total_price) as totalPrice
- 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.hour_type is not null and o.hour_type not in ('-1')
-
-
- and o.order_type = #{dto.orderType}
- and (o.appt_id is not null or o.appt_rule_id is not null)
-
- and o.order_status in
- #{v}
-
- and o.hour_type = #{dto.hourType}
- and o.merchant_id = #{dto.merchantId}
-
-
- and o.user_dept_id not in
- and o.user_dept_id in
-
- #{v}
-
-
- and o.merchant_dept_id in
- #{v}
-
-
- and o.merchant_id in
- #{v}
-
-
- group by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d'), o.hour_type, o.merchant_id, o.user_dept_id
- order by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d'), o.hour_type, o.merchant_id, o.user_dept_id
+
+ with sale_stats as (
+ select * from
+ )
+ select export_rows.statisticsDate,
+ export_rows.hourType,
+ export_rows.saleSource,
+ export_rows.merchantId,
+ export_rows.userDeptId,
+ export_rows.payUserCount,
+ export_rows.timeCount,
+ export_rows.payCount,
+ export_rows.totalPrice
+ from (
+ select sale_stats.stat_date as statisticsDate,
+ sale_stats.hour_type as hourType,
+ null as saleSource,
+ sale_stats.mch_id as merchantId,
+ sale_stats.user_dept_id as userDeptId,
+ count(distinct sale_stats.user_id) as payUserCount,
+ count(distinct sale_stats.order_id) as timeCount,
+ sum(sale_stats.product_count) as payCount,
+ sum(sale_stats.total_price) as totalPrice
+ from sale_stats
+
+
+
+ group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.mch_id, sale_stats.user_dept_id
+ union all
+ select sale_stats.stat_date as statisticsDate,
+ sale_stats.hour_type as hourType,
+ sale_stats.sale_source as saleSource,
+ sale_stats.mch_id as merchantId,
+ sale_stats.user_dept_id as userDeptId,
+ count(distinct sale_stats.user_id) as payUserCount,
+ count(distinct sale_stats.order_id) as timeCount,
+ sum(sale_stats.product_count) as payCount,
+ sum(sale_stats.total_price) as totalPrice
+ from sale_stats
+
+
+
+ group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.sale_source,
+ sale_stats.mch_id, sale_stats.user_dept_id
+ ) export_rows
+ order by export_rows.statisticsDate,
+ export_rows.hourType,
+ case when export_rows.saleSource is null then 0
+ when export_rows.saleSource = 'OFFLINE_PURCHASE' then 1 else 2 end,
+ export_rows.merchantId,
+ export_rows.userDeptId
-
-
- select DATE_FORMAT(o.appt_use_date, '%Y-%m-%d') as apptDate,
- o.hour_type as hourType,
- o.merchant_id as merchantId,
- o.user_dept_id as userDeptId,
- a.product_id as productId,
- a.product_name as productName,
- SUM(a.product_count) as payCount,
- SUM(a.total_price) as totalPrice
- 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.hour_type is not null and o.hour_type not in ('-1')
-
-
- and o.order_type = #{dto.orderType}
- and (o.appt_id is not null or o.appt_rule_id is not null)
-
- and o.order_status in
- #{v}
-
- and o.hour_type = #{dto.hourType}
- and o.merchant_id = #{dto.merchantId}
-
-
- and o.user_dept_id not in
- and o.user_dept_id in
-
- #{v}
-
-
- and o.merchant_dept_id in
- #{v}
-
-
- and o.merchant_id in
- #{v}
-
-
- group by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d'), o.hour_type, o.merchant_id, o.user_dept_id,
- a.product_id, a.product_name
- order by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d'), o.hour_type, o.merchant_id, o.user_dept_id,
- a.product_name
+
+ with sale_stats as (
+ select * from
+ )
+ select export_rows.statisticsDate,
+ export_rows.hourType,
+ export_rows.saleSource,
+ export_rows.merchantId,
+ export_rows.userDeptId,
+ export_rows.productId,
+ export_rows.productName,
+ export_rows.payCount,
+ export_rows.totalPrice
+ from (
+ select sale_stats.stat_date as statisticsDate,
+ sale_stats.hour_type as hourType,
+ null as saleSource,
+ sale_stats.mch_id as merchantId,
+ sale_stats.user_dept_id as userDeptId,
+ sale_stats.product_id as productId,
+ sale_stats.product_name as productName,
+ sum(sale_stats.product_count) as payCount,
+ sum(sale_stats.total_price) as totalPrice
+ from sale_stats
+
+
+
+ group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.mch_id, sale_stats.user_dept_id,
+ sale_stats.product_id, sale_stats.product_name
+ union all
+ select sale_stats.stat_date as statisticsDate,
+ sale_stats.hour_type as hourType,
+ sale_stats.sale_source as saleSource,
+ sale_stats.mch_id as merchantId,
+ sale_stats.user_dept_id as userDeptId,
+ sale_stats.product_id as productId,
+ sale_stats.product_name as productName,
+ sum(sale_stats.product_count) as payCount,
+ sum(sale_stats.total_price) as totalPrice
+ from sale_stats
+
+
+
+ group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.sale_source,
+ sale_stats.mch_id, sale_stats.user_dept_id, sale_stats.product_id, sale_stats.product_name
+ ) export_rows
+ order by export_rows.statisticsDate,
+ export_rows.hourType,
+ case when export_rows.saleSource is null then 0
+ when export_rows.saleSource = 'OFFLINE_PURCHASE' then 1 else 2 end,
+ export_rows.merchantId,
+ export_rows.userDeptId,
+ export_rows.productName
-
select a.product_id,
a.product_name,
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/fz/ThirdMchFzRelationSceneEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/fz/ThirdMchFzRelationSceneEnum.java
new file mode 100644
index 00000000..2c9648da
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/fz/ThirdMchFzRelationSceneEnum.java
@@ -0,0 +1,24 @@
+package com.bsd.pjcloud.thirdparty.constant.fz;
+
+import lombok.Getter;
+
+/**
+ * 商户分账关系业务场景,编码用于区分同一渠道账号未来可能增加的其他分账用途。
+ */
+@Getter
+public enum ThirdMchFzRelationSceneEnum {
+ /** 商户支付成功后向平台收取服务费的默认分佣关系。 */
+ DEFAULT_PLATFORM_COMMISSION("DEFAULT_PLATFORM_COMMISSION");
+
+ /** 关系表持久化的稳定业务场景编码。 */
+ private final String code;
+
+ /**
+ * 创建关系场景。
+ *
+ * @param code 持久化场景编码
+ */
+ ThirdMchFzRelationSceneEnum(String code) {
+ this.code = code;
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/fz/ThirdMchFzRelationStatusEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/fz/ThirdMchFzRelationStatusEnum.java
new file mode 100644
index 00000000..9b40c3eb
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/fz/ThirdMchFzRelationStatusEnum.java
@@ -0,0 +1,32 @@
+package com.bsd.pjcloud.thirdparty.constant.fz;
+
+import lombok.Getter;
+
+/**
+ * 商户渠道分账关系状态,编码持久化到 {@code third_mch_fz_relation.status},不可随意修改。
+ */
+@Getter
+public enum ThirdMchFzRelationStatusEnum {
+ /** 已建立本地关系事实,等待 Quartz 下发绑定任务。 */
+ PENDING_BIND("PENDING_BIND"),
+ /** Worker 已开始调用渠道,结果不确定时允许使用相同参数重试。 */
+ BINDING("BINDING"),
+ /** 渠道已明确确认分账关系存在,可以创建需要平台抽佣的支付订单。 */
+ BOUND("BOUND"),
+ /** 渠道返回确定性失败,等待修正配置或人工重试。 */
+ BIND_FAILED("BIND_FAILED"),
+ /** 支付配置已变化,该关系不再用于新订单,但为历史订单保留渠道关系和快照。 */
+ SUPERSEDED("SUPERSEDED");
+
+ /** 数据库和内部接口使用的稳定状态编码。 */
+ private final String code;
+
+ /**
+ * 创建关系状态。
+ *
+ * @param code 持久化状态编码
+ */
+ ThirdMchFzRelationStatusEnum(String code) {
+ this.code = code;
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPaymentTaskTypeEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPaymentTaskTypeEnum.java
index c8f66f1c..f7c4e940 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPaymentTaskTypeEnum.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPaymentTaskTypeEnum.java
@@ -33,7 +33,7 @@ public enum ThirdPaymentTaskTypeEnum {
AGGREGATE_SESSION("AGGREGATE_SESSION"),
/** 历史订单码查单并在渠道确认后关闭。 */
PAY_CLOSE("PAY_CLOSE"),
- /** 历史聚合支付会话本地关闭。 */
+ /** 历史聚合会话收口;无渠道单时本地关闭,有渠道单时物化唯一会话补偿任务。 */
AGGREGATE_CLOSE("AGGREGATE_CLOSE"),
/** 对账任务预留类型。 */
RECONCILE("RECONCILE");
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdPaymentTaskDispatchDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdPaymentTaskDispatchDto.java
index a033076c..4771cb0f 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdPaymentTaskDispatchDto.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdPaymentTaskDispatchDto.java
@@ -21,6 +21,6 @@ public class ThirdPaymentTaskDispatchDto {
private Integer batchSize = 100;
/** 触发来源,固定任务由 Quartz 传入。 */
private String triggerSource = "QUARTZ";
- /** Quartz 触发时间。 */
+ /** Quartz 原始触发时间,仅用于调用链诊断;thirdparty 必须使用自身接收时间计算数据库租约。 */
private LocalDateTime triggerTime;
}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java
new file mode 100644
index 00000000..0584deec
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java
@@ -0,0 +1,87 @@
+package com.bsd.pjcloud.thirdparty.entity.pay;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.extension.activerecord.Model;
+import com.bsd.pjcloud.common.core.util.TenantTable;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+/**
+ * 商户第三方支付分账关系事实,一条记录描述一个 {@code mch_manage} 在一个渠道下的平台分佣接收方。
+ */
+@Data
+@TenantTable
+@TableName("third_mch_fz_relation")
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "商户第三方支付分账关系")
+public class ThirdMchFzRelationEntity extends Model {
+ /** 关系主键,由应用侧雪花算法生成。 */
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+ /** 关系所属租户ID,来源于 mch_manage,作为数据隔离和任务幂等边界。 */
+ private Long tenantId;
+ /** 对应的 mch_manage 商户主键,不跨租户解释。 */
+ private Long mchId;
+ /** 产生该关系的 mch_pay_setting 主键,用于配置变更时识别历史关系。 */
+ private Long paySettingId;
+ /** 支付平台持久化编码,10表示微信、20表示支付宝。 */
+ private String platformType;
+ /** 本地关系场景编码,本期固定为 DEFAULT_PLATFORM_COMMISSION。 */
+ private String relationScene;
+ /** 微信服务商 AppID 或支付宝平台 AppID 快照,用于区分不同平台账号。 */
+ private String channelAppId;
+ /** 微信 sub_mchid 或支付宝 smid/ip_role_id,用于绑定关系与商户支付配置对应。 */
+ private String channelSubMchId;
+ /** 支付宝命名平台账号键;默认支付宝账号及微信关系为空。 */
+ private String accountKey;
+ /** 渠道接收方类型,微信为 MERCHANT_ID,支付宝为 userId 或 loginName。 */
+ private String receiverType;
+ /** 平台分佣接收账号,发起渠道请求时使用;管理端展示必须脱敏。 */
+ private String receiverAccount;
+ /** 平台分佣接收方名称快照,渠道不要求时允许为空。 */
+ private String receiverName;
+ /** 渠道关系类型,微信默认 SERVICE_PROVIDER,支付宝关系允许为空。 */
+ private String channelRelationType;
+ /** 渠道绑定稳定幂等号;支付宝重试必须保持不变,微信用于本地审计。 */
+ private String outRequestNo;
+ /** 当前关系状态,由关系 Worker 使用原状态条件推进。 */
+ private String status;
+ /** 是否为支付配置当前默认关系,0为历史关系、1为新订单使用的默认关系。 */
+ private String isDefault;
+ /** 脱敏后的绑定请求快照,不得包含 app_auth_token、私钥或公钥。 */
+ private String bindReq;
+ /** 最近一次渠道绑定或关系查询响应快照。 */
+ private String bindRes;
+ /** 最近一次绑定错误摘要,不得包含渠道凭据。 */
+ private String bindError;
+ /** 最近一次渠道结果码或外部状态,用于监控聚合。 */
+ private String lastExternalStatus;
+ /** 最近一次调用渠道绑定或查询接口的时间。 */
+ private LocalDateTime lastQueryTime;
+ /** 渠道明确确认关系已绑定的时间。 */
+ private LocalDateTime boundTime;
+ /** 审计创建人,由 MyBatis 填充器写入。 */
+ @TableField(fill = FieldFill.INSERT)
+ private String createBy;
+ /** 本地关系事实创建时间。 */
+ @TableField(fill = FieldFill.INSERT)
+ private LocalDateTime createTime;
+ /** 最近修改人,由 MyBatis 填充器维护。 */
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ private String updateBy;
+ /** 最近一次关系状态、默认标记或渠道快照更新时间。 */
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ private LocalDateTime updateTime;
+ /** 逻辑删除标记,0为正常、1为删除;历史关系默认保留而不物理删除。 */
+ @TableLogic
+ @TableField(fill = FieldFill.INSERT)
+ private String delFlag;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdPaymentTaskAttemptEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdPaymentTaskAttemptEntity.java
index 4434b13b..8f1b3d87 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdPaymentTaskAttemptEntity.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdPaymentTaskAttemptEntity.java
@@ -18,33 +18,33 @@ import java.time.LocalDateTime;
@TableName("third_payment_task_attempt")
@EqualsAndHashCode(callSuper = true)
public class ThirdPaymentTaskAttemptEntity extends Model {
- /** 尝试主键。 */
+ /** 单次执行尝试主键,由应用侧雪花算法生成。 */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
- /** 任务主键。 */
+ /** 所属支付任务主键,来源于 {@code third_payment_task.id}。 */
private Long taskId;
- /** 租户ID。 */
+ /** 尝试记录所属租户ID,复制自任务事实并参与租户数据隔离。 */
private Long tenantId;
- /** 任务内序号。 */
+ /** 任务内尝试序号,来源于任务成功领取后的累计次数,从一开始递增。 */
private Integer attemptNo;
- /** 触发来源。 */
+ /** 本次执行触发来源,例如 QUARTZ、CALLBACK、SYNC_RESPONSE 或 MANUAL。 */
private String triggerSource;
- /** Worker实例标识。 */
+ /** 实际执行本次尝试的 thirdparty Worker 实例标识,用于多实例故障定位。 */
private String workerId;
- /** 执行租约令牌。 */
+ /** 本次执行对应的租约令牌,用于关联任务 CAS 所有权;任务重领后旧令牌不再有效。 */
private String leaseToken;
- /** 脱敏请求快照。 */
+ /** 本次执行的脱敏请求 JSON;允许为空,禁止保存私钥、公钥、完整授权令牌或付款码。 */
private String requestSnapshot;
- /** 脱敏响应快照。 */
+ /** 本次执行的脱敏响应 JSON;允许为空,支付凭据及个人敏感字段必须遮蔽。 */
private String responseSnapshot;
- /** 外部状态。 */
+ /** 本次执行观察到的渠道或本地编排状态;调用前失败时允许为空。 */
private String externalStatus;
- /** 错误码。 */
+ /** 本次执行的渠道或本地错误码;成功执行时允许为空。 */
private String errorCode;
- /** 错误摘要。 */
+ /** 本次执行的脱敏错误摘要;成功执行时允许为空,禁止记录敏感配置原文。 */
private String errorMessage;
- /** 开始时间。 */
+ /** Worker 开始处理本次尝试的时间,用于计算执行耗时和定位超时租约。 */
private LocalDateTime startedTime;
- /** 结束时间。 */
+ /** 本次尝试写入成功、失败或重试结果的时间;Worker 中断且尚未回写时允许为空。 */
private LocalDateTime finishedTime;
}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdPaymentTaskEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdPaymentTaskEntity.java
index 42d42e90..c2d82513 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdPaymentTaskEntity.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdPaymentTaskEntity.java
@@ -18,64 +18,64 @@ import java.time.LocalDateTime;
@EqualsAndHashCode(callSuper = true)
@Schema(description = "第三方支付任务")
public class ThirdPaymentTaskEntity extends Model {
- /** 任务主键。 */
+ /** 任务事实主键,由应用侧雪花算法生成,不依赖数据库自增。 */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
- /** 租户ID。 */
+ /** 任务所属租户ID,来源于业务单据或当前租户上下文,也是任务领取和唯一键隔离边界。 */
private Long tenantId;
- /** 任务类型持久化编码。 */
+ /** 任务类型持久化编码,取值由 {@code ThirdPaymentTaskTypeEnum} 维护,创建后不得改变。 */
private String taskType;
- /** 租户内稳定幂等键。 */
+ /** 租户内稳定幂等键,由任务类型和原业务号组成,外部调用重试时不得重新生成。 */
private String taskKey;
- /** 支付订单主键。 */
+ /** 关联支付订单主键;非支付订单类任务允许为空。 */
private Long payOrderId;
- /** 分账订单主键。 */
+ /** 关联分账订单主键;非分账及退分账任务允许为空。 */
private Long fzOrderId;
- /** 聚合会话主键。 */
+ /** 关联聚合支付会话主键;非聚合支付任务允许为空。 */
private Long aggregateSessionId;
- /** 稳定支付单号。 */
+ /** 平台内稳定支付单号,来源于支付订单;未关联支付订单的任务允许为空。 */
private String payNo;
- /** 稳定退款单号。 */
+ /** 平台内稳定退款单号,来源于退款业务;非退款任务允许为空。 */
private String refundNo;
- /** 稳定分账单号。 */
+ /** 平台内稳定分账单号,来源于分账订单;非分账任务允许为空。 */
private String fzNo;
- /** 稳定聚合会话号。 */
+ /** 聚合支付会话稳定业务号,来源于聚合会话;非聚合支付任务允许为空。 */
private String aggregateNo;
- /** 当前任务状态。 */
+ /** 当前任务状态持久化编码,由任务状态机通过 CAS 更新,禁止外部直接跳转终态。 */
private String status;
- /** 下一次可执行时间。 */
+ /** 下一次允许领取的数据库时间;READY/RETRY 到期后才可由 Quartz 分发触发领取。 */
private LocalDateTime nextRunTime;
- /** 优先级,越大越优先。 */
+ /** 同批到期任务的领取优先级,数值越大越先领取。 */
private Integer priority;
- /** Worker实例标识。 */
+ /** 当前租约持有的 thirdparty Worker 实例标识;未领取或任务终态时为空。 */
private String leaseOwner;
- /** 当前租约令牌。 */
+ /** 当前执行租约的随机令牌,完成更新必须匹配该值,未领取或任务终态时为空。 */
private String leaseToken;
- /** 租约到期时间。 */
+ /** 当前执行租约到期时间;只约束 Worker 所有权,不代表外部渠道业务超时。 */
private LocalDateTime leaseExpireTime;
- /** 累计尝试次数。 */
+ /** 任务累计领取尝试次数,每次 CAS 领取成功后递增,初始值为零。 */
private Integer attemptCount;
- /** 最近外部渠道状态。 */
+ /** 最近一次执行观察到的支付宝、微信或本地编排状态;尚未执行时允许为空。 */
private String lastExternalStatus;
- /** 最近错误码。 */
+ /** 最近一次失败的渠道或本地错误码;最近执行成功或尚无错误时允许为空。 */
private String lastErrorCode;
- /** 最近错误摘要。 */
+ /** 最近一次失败的脱敏错误摘要,不得包含密钥、完整授权令牌或付款码。 */
private String lastErrorMessage;
- /** 任务终态时间。 */
+ /** 进入 SUCCESS、FAILED、ALERT 或 CANCELLED 等终态的时间;处理中任务为空。 */
private LocalDateTime finishedTime;
- /** 创建人。 */
+ /** 审计创建人,由 MyBatis 填充器写入;系统任务允许为空或使用系统身份。 */
@TableField(fill = FieldFill.INSERT)
private String createBy;
- /** 创建时间。 */
+ /** 任务事实创建时间,由应用显式写入或 MyBatis 填充器补齐。 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
- /** 修改人。 */
+ /** 最近修改人,由 MyBatis 填充器维护;系统任务允许为空或使用系统身份。 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
- /** 修改时间。 */
+ /** 最近一次任务状态、租约或诊断信息变更时间。 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
- /** 逻辑删除标记。 */
+ /** 逻辑删除标记,沿用项目通用 {@code 0/1} 约定;任务状态机不得物理删除任务事实。 */
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPaymentTaskDispatchVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPaymentTaskDispatchVo.java
index 436b3dd8..d7633608 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPaymentTaskDispatchVo.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPaymentTaskDispatchVo.java
@@ -13,7 +13,7 @@ public class ThirdPaymentTaskDispatchVo {
private int acceptedCount;
/** 成功取得租约的任务数。 */
private int claimedCount;
- /** 当前 Worker 正在执行数量。 */
+ /** 当前 thirdparty 实例正在执行及已进入有界队列的任务数量。 */
private int processingCount;
/** 队列是否达到容量上限。 */
private boolean busy;
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJob.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJob.java
index 726c9b0f..28cf6ebb 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJob.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJob.java
@@ -2,15 +2,11 @@ package com.bsd.pjcloud.thirdparty.job;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum;
-import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
-import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
-import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayPollingCoordinator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -48,21 +44,15 @@ public class ThirdAggregateOrderPayJob {
private final ThirdAggregateOrderPayService aggregateOrderPayService;
/**
- * 第三方支付子订单服务,用于在一小时硬截止后仅做本地关闭标记。
- */
- private final ThirdPayOrderService thirdPayOrderService;
-
- /**
- * 服务端轮询协调器,用于在无活动会话时退避数据库扫描,并在新会话创建后恢复短间隔。
+ * 旧兼容入口的进程内门控器;不创建线程,也不是支付周期调度来源。
*/
private final ThirdPayPollingCoordinator pollingCoordinator;
/**
* 分批处理等待扫码、平台创建中和支付中的聚合会话。
*
- * 调度线程初始没有租户上下文,单条处理前使用记录自身 tenantId 恢复上下文;
- * Redis 键仍由公共基础设施自动附加租户前缀。方法每五秒轻量触发一次,但连续空结果时协调器会把
- * 真正的数据库扫描逐级退避到最长六十秒;创建超过一小时的记录不再进入高频处理。
+ * 该方法没有 {@code @Scheduled},仅保留为显式诊断兼容入口。单条处理前使用记录自身 tenantId
+ * 恢复租户上下文;生产环境由 Quartz 分发任务表记录,不调用本方法形成 thirdparty 兜底调度。
*/
public void handlePendingSessions() {
if (!pollingCoordinator.tryStartAggregatePendingPolling()) {
@@ -102,11 +92,11 @@ public class ThirdAggregateOrderPayJob {
}
/**
- * 补偿处理已到期但子订单尚未确认关闭的聚合会话,并关闭超过一小时的历史记录。
+ * 补偿处理已到期但子订单尚未确认关闭的聚合会话,并继续核对超过一小时的历史记录。
*
* 补偿窗口从会话原始失效时间开始计算,最多持续十分钟;因此客户端退出、断网或不再查询状态时,
- * 服务端仍会独立完成有限次查单和关单。连续空结果时数据库扫描从三十秒逐级退避到最长五分钟,
- * 超过一小时后只把会话及未支付子订单标记为关闭,不再请求支付宝或微信。
+ * 服务端仍会独立完成查单和关单。该兼容方法没有 {@code @Scheduled},周期分发完全由 Quartz 和
+ * {@code third_payment_task} 承担;存在渠道子订单的历史记录也不能本地盲关。
*/
public void handleExpiredSessionCompensation() {
if (!pollingCoordinator.tryStartAggregateCompensationPolling()) {
@@ -157,10 +147,10 @@ public class ThirdAggregateOrderPayJob {
}
/**
- * 将超过一小时的过期聚合会话及其未支付子订单标记为交易关闭。
+ * 处理超过一小时仍未收敛的聚合会话。
*
- * 该分支不调用任何支付平台接口,也不再根据子订单恢复会话。会话先进入关闭终态,随后仅把仍为
- * 未支付的子订单标记为关闭;已经落库的真实支付结果保留给人工对账,但不会重新激活聚合会话。
+ * 方法只保留为旧诊断入口,不承担周期调度。每条记录仍交给聚合支付编排服务,后者对有渠道子单的
+ * 会话执行查单或关单,对未知结果抛出异常并保留待处理状态。
*
* @param sessions 超过一小时且仍处于活动或过期状态的聚合会话
*/
@@ -175,16 +165,7 @@ public class ThirdAggregateOrderPayJob {
}
try {
TenantBroker.runAs(session.getTenantId(), tenantId -> {
- aggregatePaySessionService.markTerminal(
- session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
- ThirdPayOrderEntity payOrder = session.getPayNo() == null
- ? null : thirdPayOrderService.getByPayNo(session.getPayNo());
- if (payOrder != null) {
- thirdPayOrderService.update(new LambdaUpdateWrapper()
- .eq(ThirdPayOrderEntity::getId, payOrder.getId())
- .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType())
- .set(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_CLOSED.getType()));
- }
+ aggregateOrderPayService.processPendingSession(session.getId());
});
} catch (Exception exception) {
log.warn("关闭历史聚合支付会话失败,sessionId:{},tenantId:{},exceptionType:{}",
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJob.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJob.java
index b18fd8cb..d2ea8ac1 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJob.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJob.java
@@ -53,16 +53,6 @@ public class ThirdAppPlusPayJob {
*/
private static final long ORDER_CODE_CLOSE_COMPENSATION_MINUTES = 10L;
- /**
- * 超过历史恢复时间的原生订单码本地关闭标记周期,单位毫秒。
- */
- private static final long ORDER_CODE_HISTORY_CLOSE_INTERVAL_MILLIS = 300000L;
-
- /**
- * 服务启动后首次标记历史订单码的等待时间,单位毫秒。
- */
- private static final long ORDER_CODE_HISTORY_CLOSE_INITIAL_DELAY_MILLIS = 60000L;
-
/**
* 支付订单服务,用于分批读取仍未支付的订单码记录。
*/
@@ -89,7 +79,7 @@ public class ThirdAppPlusPayJob {
private final ThirdAppPlusAliPayService thirdAppPlusAliPayService;
/**
- * 服务端轮询协调器,用于在无待处理订单时退避数据库扫描,并在创建订单后恢复短间隔。
+ * 旧兼容入口的进程内门控器;不创建线程,也不承担生产支付调度。
*/
private final ThirdPayPollingCoordinator pollingCoordinator;
@@ -118,9 +108,8 @@ public class ThirdAppPlusPayJob {
/**
* 分批处理支付宝和微信云消费机订单码,最长处理到订单过期后十分钟。
*
- * 初始查询在线程无租户上下文时跨租户读取,处理单条订单前通过 TenantBroker
- * 恢复订单所属租户,确保后续读取和原子更新都受正确租户条件约束。方法每五秒轻量触发一次,
- * 但连续空结果时协调器会把真正的数据库扫描逐级退避到最长六十秒。
+ * 该方法没有 {@code @Scheduled},仅保留为显式诊断兼容入口。处理单条订单前通过 TenantBroker
+ * 恢复订单所属租户;生产周期调度只由 Quartz 分发 {@code third_payment_task}。
*/
public void handleOrderCodeOrders() {
if (!pollingCoordinator.tryStartOrderCodePolling()) {
@@ -454,10 +443,10 @@ public class ThirdAppPlusPayJob {
}
/**
- * 将超过一小时仍未支付的原生支付宝、微信订单码标记为交易关闭。
+ * 核对超过一小时仍未支付的原生支付宝、微信订单码。
*
- * 该任务只执行带未支付状态条件的数据库更新,不再向支付宝或微信发起查单、撤销或关单请求;
- * 平台若在硬截止后出现延迟支付,由人工渠道核对处理。
+ * 该方法没有 {@code @Scheduled},只保留为显式诊断入口。历史年龄不能替代渠道终态,
+ * 因此仍复用支付编排服务按订单配置快照查单或关单,未知结果保持未支付。
*/
public void closeHistoricalOrderCodeOrders() {
LocalDateTime cutoff = LocalDateTime.now()
@@ -482,15 +471,11 @@ public class ThirdAppPlusPayJob {
}
try {
TenantBroker.runAs(order.getTenantId(), tenantId -> {
- ThirdPayOrderEntity update = new ThirdPayOrderEntity();
- update.setStatus(ThirdPayOrderStatusEnum.PAY_CLOSED.getType());
- thirdPayOrderService.update(update, new LambdaUpdateWrapper()
- .eq(ThirdPayOrderEntity::getId, order.getId())
- .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType())
- .and(timeWrapper -> timeWrapper
- .isNull(ThirdPayOrderEntity::getCreateTime)
- .or()
- .lt(ThirdPayOrderEntity::getCreateTime, cutoff)));
+ ThirdPayOrderEntity latest = thirdPayOrderService.getById(order.getId());
+ if (latest != null && ObjectUtil.equals(
+ latest.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) {
+ thirdAppPlusService.queryPayResult(latest);
+ }
});
} catch (Exception exception) {
log.warn("关闭历史订单码失败,orderId:{},tenantId:{},platform:{},exceptionType:{}",
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdMchFzRelationMapper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdMchFzRelationMapper.java
new file mode 100644
index 00000000..5e99d47b
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdMchFzRelationMapper.java
@@ -0,0 +1,12 @@
+package com.bsd.pjcloud.thirdparty.mapper.pay;
+
+import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 商户分账关系数据访问,关系领取与结果推进使用 Service 层的状态条件更新。
+ */
+@Mapper
+public interface ThirdMchFzRelationMapper extends PjcloudBaseMapper {
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapper.java
index 7c8a6f14..9d2e329e 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapper.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapper.java
@@ -68,6 +68,20 @@ public interface ThirdPaymentTaskMapper extends PjcloudBaseMapper支付平台回调仍是结果确认主路径;本协调器只控制数据库补偿扫描频率。有待处理记录时维持短间隔,
- * 连续空扫描时指数退避,订单创建成功后由当前服务实例主动唤醒,从而避免全天执行无效 SQL。
+ * 本类不创建线程、不注册定时器,也不会主动扫描数据库。它只供保留的兼容诊断方法控制同进程重入;
+ * 生产支付任务的唯一周期来源是 Quartz,真实恢复状态由 {@code third_payment_task} 保存。
*/
@Component
public class ThirdPayPollingCoordinator {
@@ -24,9 +24,9 @@ public class ThirdPayPollingCoordinator {
public static final long COMPENSATION_TRIGGER_INTERVAL_MILLIS = 30000L;
/**
- * 订单码允许服务端自动查单、关单或恢复会话状态的最长时间,单位分钟。
+ * 旧批处理区分活动记录和历史记录的时间窗口,单位分钟。
*
- * 超过该硬截止后只允许本地标记关闭,客户端查询、定时任务和创建重试均不得再次访问支付平台。
+ * 该窗口只影响扫描分组,不能作为本地关闭渠道订单的依据;存在渠道单号时仍须查单或关单确认。
*/
public static final long MAX_AUTO_PROCESSING_MINUTES = 60L;
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuer.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuer.java
index f6a025a1..63ca7af9 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuer.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuer.java
@@ -53,32 +53,74 @@ public class ThirdPaymentTaskEnqueuer {
payOrderId, payNo, null, null, null);
}
- /** 为已创建的分账记录排入微信分账查单任务。 */
+ /**
+ * 为已创建的微信分账记录排入稳定查单任务。
+ *
+ * @param tenantId 分账订单所属租户ID;为空时读取当前租户上下文
+ * @param payOrderId 原支付订单主键
+ * @param fzOrderId 分账订单主键
+ * @param payNo 原支付订单稳定支付单号
+ * @param fzNo 微信分账稳定业务单号
+ */
public void enqueueFzQuery(Long tenantId, Long payOrderId, Long fzOrderId, String payNo, String fzNo) {
enqueueAt(tenantId, ThirdPaymentTaskTypeEnum.FZ_QUERY, "FZ_QUERY:" + fzNo,
payOrderId, payNo, null, fzOrderId, fzNo, LocalDateTime.now(), 100);
}
- /** 为支付宝直付通排入确认结算任务,默认至少等待支付成功三十秒。 */
+ /**
+ * 为支付宝直付通排入确认结算任务,调用方必须传入不早于支付成功约三十秒的执行时间。
+ *
+ * @param tenantId 支付订单所属租户ID;为空时读取当前租户上下文
+ * @param payOrderId 原支付订单主键
+ * @param fzOrderId 直付通分账阶段记录主键
+ * @param payNo 原支付订单稳定支付单号
+ * @param fzNo 直付通稳定分账业务号
+ * @param nextRunTime 最早确认结算时间;为空时立即可领取,仅供显式人工恢复使用
+ */
public void enqueueFzSettleConfirm(Long tenantId, Long payOrderId, Long fzOrderId, String payNo, String fzNo,
LocalDateTime nextRunTime) {
enqueueAt(tenantId, ThirdPaymentTaskTypeEnum.FZ_SETTLE_CONFIRM, "FZ_SETTLE_CONFIRM:" + payNo,
payOrderId, payNo, null, fzOrderId, fzNo, nextRunTime, 100);
}
- /** 为确认结算成功的支付宝订单排入平台抽佣提交任务。 */
+ /**
+ * 为确认结算成功的支付宝订单排入平台抽佣提交任务。
+ *
+ * @param tenantId 支付订单所属租户ID;为空时读取当前租户上下文
+ * @param payOrderId 原支付订单主键
+ * @param fzOrderId 直付通分账阶段记录主键
+ * @param payNo 原支付订单稳定支付单号
+ * @param fzNo 直付通稳定分账业务号
+ */
public void enqueueFzRoyalty(Long tenantId, Long payOrderId, Long fzOrderId, String payNo, String fzNo) {
enqueueAt(tenantId, ThirdPaymentTaskTypeEnum.FZ_ROYALTY, "FZ_ROYALTY:" + payNo,
payOrderId, payNo, null, fzOrderId, fzNo, LocalDateTime.now(), 100);
}
- /** 为支付宝平台抽佣受理结果排入稳定查询任务。 */
+ /**
+ * 为支付宝平台抽佣受理结果排入稳定查询任务。
+ *
+ * @param tenantId 支付订单所属租户ID;为空时读取当前租户上下文
+ * @param payOrderId 原支付订单主键
+ * @param fzOrderId 直付通分账阶段记录主键
+ * @param payNo 原支付订单稳定支付单号
+ * @param fzNo 直付通稳定分账业务号
+ */
public void enqueueFzRoyaltyQuery(Long tenantId, Long payOrderId, Long fzOrderId, String payNo, String fzNo) {
enqueueAt(tenantId, ThirdPaymentTaskTypeEnum.FZ_ROYALTY_QUERY, "FZ_ROYALTY_QUERY:" + payNo,
payOrderId, payNo, null, fzOrderId, fzNo, LocalDateTime.now(), 100);
}
- /** 为已发起的分账退款排入稳定查单任务。 */
+ /**
+ * 为已发起的微信或支付宝分账退款排入稳定查单任务。
+ *
+ * @param tenantId 分账退款所属租户ID;为空时读取当前租户上下文
+ * @param payOrderId 原支付订单主键
+ * @param fzOrderId 原分账订单主键
+ * @param payNo 原支付订单稳定支付单号
+ * @param fzNo 原分账稳定业务单号
+ * @param fzRefundNo 分账退款稳定业务号,用于区分同一分账单的退款尝试
+ */
public void enqueueFzRefundQuery(Long tenantId, Long payOrderId, Long fzOrderId, String payNo, String fzNo,
String fzRefundNo) {
enqueueAt(tenantId, ThirdPaymentTaskTypeEnum.FZ_REFUND_QUERY,
@@ -86,6 +128,39 @@ public class ThirdPaymentTaskEnqueuer {
payOrderId, payNo, null, fzOrderId, fzNo, LocalDateTime.now(), 110);
}
+ /**
+ * 为历史聚合会话补建唯一的状态收敛任务,避免 {@code AGGREGATE_CLOSE} 与活动会话任务并发查同一渠道单。
+ *
+ * @param tenantId 会话所属租户ID,不允许为空
+ * @param aggregateSessionId 聚合会话主键
+ * @param aggregateNo 聚合会话稳定业务号
+ * @param payNo 已绑定的渠道支付单号
+ * @param nextRunTime 最早执行时间;为空时立即可执行
+ */
+ public void enqueueAggregateSession(Long tenantId, Long aggregateSessionId, String aggregateNo, String payNo,
+ LocalDateTime nextRunTime) {
+ if (tenantId == null || aggregateSessionId == null || aggregateNo == null || aggregateNo.isBlank()) {
+ throw new IllegalArgumentException("聚合支付任务租户、会话主键和会话号不能为空");
+ }
+ LocalDateTime now = LocalDateTime.now();
+ ThirdPaymentTaskEntity task = new ThirdPaymentTaskEntity();
+ task.setId(IdWorker.getId());
+ task.setTenantId(tenantId);
+ task.setTaskType(ThirdPaymentTaskTypeEnum.AGGREGATE_SESSION.getCode());
+ task.setTaskKey("AGGREGATE_SESSION:" + aggregateNo);
+ task.setAggregateSessionId(aggregateSessionId);
+ task.setAggregateNo(aggregateNo);
+ task.setPayNo(payNo);
+ task.setStatus("READY");
+ task.setNextRunTime(nextRunTime == null ? now : nextRunTime);
+ task.setPriority(105);
+ task.setAttemptCount(0);
+ task.setCreateTime(now);
+ task.setUpdateTime(now);
+ task.setDelFlag("0");
+ taskMapper.insertIgnore(task);
+ }
+
/**
* 写入一个稳定的任务事实。
* @param tenantId 租户ID
@@ -103,7 +178,20 @@ public class ThirdPaymentTaskEnqueuer {
LocalDateTime.now(), 100);
}
- /** 写入指定执行时间和优先级的任务,外部业务号和任务键保持不变。 */
+ /**
+ * 写入指定执行时间和优先级的任务,外部业务号和任务键保持不变。
+ *
+ * @param tenantId 租户ID;为空时读取当前租户上下文
+ * @param type 任务类型,持久化编码由枚举提供
+ * @param taskKey 租户内稳定幂等键
+ * @param payOrderId 支付订单主键,非支付订单任务允许为空
+ * @param payNo 稳定支付单号,未关联支付订单时允许为空
+ * @param refundNo 稳定退款单号,非退款任务允许为空
+ * @param fzOrderId 分账订单主键,非分账任务允许为空
+ * @param fzNo 稳定分账单号,非分账任务允许为空
+ * @param nextRunTime 最早执行时间;为空时立即可执行
+ * @param priority 调度优先级,数值越大越先领取
+ */
public void enqueueAt(Long tenantId, ThirdPaymentTaskTypeEnum type, String taskKey, Long payOrderId,
String payNo, String refundNo, Long fzOrderId, String fzNo,
LocalDateTime nextRunTime, int priority) {
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java
index d1c8d862..49e106fd 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java
@@ -156,7 +156,10 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.getAliPayConfig();
request.setNotifyUrl(aliPayConfig.getBaseCallbackUrl() + aliPayConfig.getPayCallbackUrl());
request.setBizModel(model);
- request.putOtherTextParam(APP_AUTH_TOKEN_PARAM, aliPaySetting.getAppAuthToken());
+ // app_auth_token 仅在商户配置了代调用授权时发送;为空时必须让支付宝按平台默认授权处理。
+ if (StrUtil.isNotBlank(aliPaySetting.getAppAuthToken())) {
+ request.putOtherTextParam(APP_AUTH_TOKEN_PARAM, aliPaySetting.getAppAuthToken());
+ }
payOrder.setPayRep(buildRequestSnapshot(context).toString());
AlipayTradeCreateResponse response;
@@ -296,19 +299,16 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
}
/**
- * 校验商户支付宝授权配置并解析 app_auth_token。
+ * 校验商户支付宝配置并解析可选 app_auth_token。
*
* @param paySetting 当前商户启用的支付宝支付配置
- * @return 包含非空商户授权令牌的配置
+ * @return 支付宝配置;授权令牌为空时仍允许直付通请求继续,由支付宝返回明确权限结果
*/
private MchPaySettingEntity.AliPaySetting parseAliPaySetting(MchPaySettingVo paySetting) {
if (paySetting == null || StrUtil.isBlank(paySetting.getSettingJson())) {
throw new RuntimeException("商户支付宝支付配置不完整");
}
MchPaySettingEntity.AliPaySetting setting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
- if (setting == null || StrUtil.isBlank(setting.getAppAuthToken())) {
- throw new RuntimeException("商户支付宝授权Token未配置");
- }
return setting;
}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java
index 0a2ac5dc..e801740b 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java
@@ -449,10 +449,11 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
if (ObjectUtil.notEqual(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) {
return;
}
- if (ObjectUtil.equals(session.getSelectedPlatform(), AppPlusPlatformTypeEnum.ALI_PAY.getType())) {
- processAlipayOrder(session, payOrder);
- } else if (ObjectUtil.equals(session.getSelectedPlatform(), AppPlusPlatformTypeEnum.WECHAT.getType())) {
- processWechatOrder(session, payOrder);
+ String platformType = StrUtil.blankToDefault(session.getSelectedPlatform(), payOrder.getPayPlatformType());
+ if (ObjectUtil.equals(platformType, AppPlusPlatformTypeEnum.ALI_PAY.getType())) {
+ processAlipayOrder(session, payOrder);
+ } else if (ObjectUtil.equals(platformType, AppPlusPlatformTypeEnum.WECHAT.getType())) {
+ processWechatOrder(session, payOrder);
}
}
@@ -971,21 +972,6 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
return session == null || session.getCreateTime() == null || !session.getCreateTime().isAfter(cutoff);
}
- /**
- * 关闭没有渠道子订单的历史聚合会话。
- *
- * 方法故意拒绝处理带 {@code payNo} 的会话。渠道单号一旦存在,外部支付事实就可能已经产生,
- * 必须由查单、关单或已验签回调决定终态,不能通过本地年龄绕过渠道。
- *
- * @param session 未绑定渠道子订单的历史会话
- */
- private void closeHistoricalSessionLocally(ThirdAggregatePaySessionEntity session) {
- if (session == null || StrUtil.isNotBlank(session.getPayNo())) {
- return;
- }
- aggregatePaySessionService.markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
- }
-
/**
* 阻止同业务存在未归属于聚合会话的旧原生订单码或既有成功订单。
*
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java
index b1624911..3d4f2a02 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java
@@ -777,10 +777,11 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
if (thirdFzOrderService == null || payOrder == null
|| ObjectUtil.notEqual(payOrder.getPayPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY.getType())
|| ObjectUtil.notEqual(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())
- || ObjectUtil.notEqual(payOrder.getEnableFz(), com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode())
- || payOrder.getPayTime() == null) {
+ || ObjectUtil.notEqual(payOrder.getEnableFz(), com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode())) {
return null;
}
+ // 历史成功订单可能因旧实现没有写 pay_time;这里只物化本地分账事实,不访问支付宝。
+ // 最早确认结算时间由任务 Worker 以“发现时间 + 30 秒”兜底,不能在这里永久阻断恢复。
ThirdFzOrderEntity existing = thirdFzOrderService.getByPayNo(payOrder.getPayNo());
if (existing != null) {
return existing;
@@ -1042,6 +1043,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
*/
private boolean saveDirectPayStage(ThirdFzOrderEntity fzOrder, JSONObject snapshot, JSONObject stage,
ThirdFzOrderStatusEnum terminalStatus) {
+ String expectedCreateRes = fzOrder.getCreateRes();
ThirdFzOrderEntity update = new ThirdFzOrderEntity();
update.setCreateRes(snapshot.toString());
JSONObject request = stage == null ? null : stage.getJSONObject("request");
@@ -1054,9 +1056,16 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
if (terminalStatus != null) {
update.setStatus(terminalStatus.getType());
}
- boolean changed = thirdFzOrderService.update(update, new LambdaUpdateWrapper()
+ LambdaUpdateWrapper condition = new LambdaUpdateWrapper()
.eq(ThirdFzOrderEntity::getId, fzOrder.getId())
- .eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_UNPAID.getType()));
+ .eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_UNPAID.getType());
+ // 阶段快照本身也是并发版本号:旧 Worker 不能用缺少后续阶段的 JSON 覆盖新 Worker 的结果。
+ if (expectedCreateRes == null) {
+ condition.isNull(ThirdFzOrderEntity::getCreateRes);
+ } else {
+ condition.eq(ThirdFzOrderEntity::getCreateRes, expectedCreateRes);
+ }
+ boolean changed = thirdFzOrderService.update(update, condition);
if (!changed) {
return false;
}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java
index 9e39989a..959672cc 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java
@@ -155,8 +155,8 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl过期只允许从活动状态写入;支付成功或平台关闭还允许覆盖过期状态,用于接收关单补偿期间
- * 查询到的真实平台终态,但不会覆盖已经确认的成功或关闭结果。
+ * 过期只允许从活动状态写入;平台关闭允许覆盖过期状态。支付成功还允许覆盖本地已关闭状态,
+ * 仅用于恢复已经验签并完成关键字段校验的延迟成功回调,确保真实扣款事实优先于本地超时判断。
*
* @param sessionId 聚合会话主键
* @param status 目标终态
@@ -173,16 +173,23 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl sourceStatuses = ObjectUtil.equals(status, ThirdAggregatePaySessionStatusEnum.EXPIRED)
- ? List.of(
- ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(),
- ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(),
- ThirdAggregatePaySessionStatusEnum.PAYING.getType())
- : List.of(
- ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(),
- ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(),
- ThirdAggregatePaySessionStatusEnum.PAYING.getType(),
- ThirdAggregatePaySessionStatusEnum.EXPIRED.getType());
+ List sourceStatuses;
+ if (ObjectUtil.equals(status, ThirdAggregatePaySessionStatusEnum.EXPIRED)) {
+ sourceStatuses = List.of(ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(),
+ ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(),
+ ThirdAggregatePaySessionStatusEnum.PAYING.getType());
+ } else if (ObjectUtil.equals(status, ThirdAggregatePaySessionStatusEnum.PAY_SUCCESS)) {
+ sourceStatuses = List.of(ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(),
+ ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(),
+ ThirdAggregatePaySessionStatusEnum.PAYING.getType(),
+ ThirdAggregatePaySessionStatusEnum.EXPIRED.getType(),
+ ThirdAggregatePaySessionStatusEnum.PAY_CLOSED.getType());
+ } else {
+ sourceStatuses = List.of(ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(),
+ ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(),
+ ThirdAggregatePaySessionStatusEnum.PAYING.getType(),
+ ThirdAggregatePaySessionStatusEnum.EXPIRED.getType());
+ }
return update(update, new LambdaUpdateWrapper()
.eq(ThirdAggregatePaySessionEntity::getId, sessionId)
.in(ThirdAggregatePaySessionEntity::getStatus, sourceStatuses));
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java
new file mode 100644
index 00000000..af5442ce
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java
@@ -0,0 +1,456 @@
+package com.bsd.pjcloud.thirdparty.service.impl.pay;
+
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.json.JSONUtil;
+import com.alipay.api.AlipayClient;
+import com.alipay.api.AlipayConfig;
+import com.alipay.api.DefaultAlipayClient;
+import com.alipay.api.domain.AlipayTradeRoyaltyRelationBatchqueryModel;
+import com.alipay.api.domain.AlipayTradeRoyaltyRelationBindModel;
+import com.alipay.api.domain.RoyaltyEntity;
+import com.alipay.api.request.AlipayTradeRoyaltyRelationBatchqueryRequest;
+import com.alipay.api.request.AlipayTradeRoyaltyRelationBindRequest;
+import com.alipay.api.response.AlipayTradeRoyaltyRelationBatchqueryResponse;
+import com.alipay.api.response.AlipayTradeRoyaltyRelationBindResponse;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.core.toolkit.IdWorker;
+import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
+import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
+import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
+import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationSceneEnum;
+import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationStatusEnum;
+import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType;
+import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
+import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdMchFzRelationMapper;
+import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
+import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer;
+import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
+import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
+import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
+import com.github.yulichang.toolkit.MPJReflectionKit;
+import com.wechat.pay.java.service.profitsharing.ProfitsharingService;
+import com.wechat.pay.java.service.profitsharing.model.AddReceiverRequest;
+import com.wechat.pay.java.service.profitsharing.model.AddReceiverResponse;
+import com.wechat.pay.java.service.profitsharing.model.ReceiverRelationType;
+import com.wechat.pay.java.service.profitsharing.model.ReceiverType;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 商户微信/支付宝默认平台分佣关系实现。
+ *
+ * 关系绑定是支付前置条件,但外部渠道调用由 Quartz 的
+ * {@code FZ_RELATION_BIND} 任务执行;本服务只负责关系事实物化、渠道请求和结果快照。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService {
+
+ /** 微信支付平台持久化编码。 */
+ private static final String WECHAT_TYPE = AppPlusPlatformPayType.WECHAT.getType();
+ /** 支付宝支付平台持久化编码。 */
+ private static final String ALIPAY_TYPE = AppPlusPlatformPayType.ALI_PAY.getType();
+ /** 关系绑定的固定业务场景。 */
+ private static final String DEFAULT_SCENE = ThirdMchFzRelationSceneEnum.DEFAULT_PLATFORM_COMMISSION.getCode();
+ /** 本地关系绑定任务的优先级,需早于支付创建后的分账任务。 */
+ private static final int RELATION_TASK_PRIORITY = 150;
+
+ /** 关系事实 Mapper。 */
+ private final ThirdMchFzRelationMapper relationMapper;
+ /** 关系绑定任务写入器。 */
+ private final ThirdPaymentTaskEnqueuer taskEnqueuer;
+ /** 微信服务商分账 SDK。 */
+ private final ProfitsharingService profitsharingService;
+ /** 应用级支付宝、微信平台配置。 */
+ private final AppPlusPayConfig appPlusPayConfig;
+
+ /**
+ * 物化当前商户启用且配置抽佣比例的默认关系,并把旧配置标记为历史关系。
+ *
+ * @param merchant 商户内部快照
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void syncDefaultRelations(MchManageVo merchant) {
+ if (merchant == null || merchant.getId() == null) {
+ return;
+ }
+ Long tenantId = merchant.getTenantId() == null ? TenantContextHolder.getTenantId() : merchant.getTenantId();
+ if (tenantId == null) {
+ throw new IllegalStateException("商户分账关系租户不能为空");
+ }
+ List settings = merchant.getPaySettingList() == null
+ ? List.of() : merchant.getPaySettingList();
+ List currentSettingIds = new ArrayList<>();
+ for (MchPaySettingVo setting : settings) {
+ if (!isEligible(setting)) {
+ continue;
+ }
+ currentSettingIds.add(setting.getId());
+ ThirdMchFzRelationEntity relation = buildRelation(tenantId, merchant, setting);
+ ThirdMchFzRelationEntity existing = relationMapper.selectOne(new LambdaQueryWrapper()
+ .eq(ThirdMchFzRelationEntity::getTenantId, tenantId)
+ .eq(ThirdMchFzRelationEntity::getPaySettingId, setting.getId())
+ .eq(ThirdMchFzRelationEntity::getPlatformType, relation.getPlatformType())
+ .eq(ThirdMchFzRelationEntity::getChannelAppId, relation.getChannelAppId())
+ .eq(ThirdMchFzRelationEntity::getChannelSubMchId, relation.getChannelSubMchId())
+ .eq(ThirdMchFzRelationEntity::getReceiverType, relation.getReceiverType())
+ .eq(ThirdMchFzRelationEntity::getReceiverAccount, relation.getReceiverAccount())
+ .eq(ThirdMchFzRelationEntity::getRelationScene, DEFAULT_SCENE)
+ .eq(ThirdMchFzRelationEntity::getDelFlag, "0"));
+ if (existing == null) {
+ relationMapper.insert(relation);
+ existing = relation;
+ }
+ relationMapper.update(null, new LambdaUpdateWrapper()
+ .eq(ThirdMchFzRelationEntity::getTenantId, tenantId)
+ .eq(ThirdMchFzRelationEntity::getPaySettingId, setting.getId())
+ .eq(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode())
+ .ne(ThirdMchFzRelationEntity::getId, existing.getId())
+ .eq(ThirdMchFzRelationEntity::getDelFlag, "0")
+ .set(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.NO.getCode())
+ .set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode()));
+ if (!ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(existing.getStatus())
+ && !ThirdMchFzRelationStatusEnum.BINDING.getCode().equals(existing.getStatus())) {
+ taskEnqueuer.enqueueAt(tenantId, com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum.FZ_RELATION_BIND,
+ "FZ_RELATION_BIND:" + existing.getId(), null, null, null, null, null,
+ LocalDateTime.now(), RELATION_TASK_PRIORITY);
+ }
+ }
+ // 配置被禁用、删除或改成其他平台账号时,旧关系只停止接新单,不删除渠道事实。
+ LambdaUpdateWrapper stale = new LambdaUpdateWrapper()
+ .eq(ThirdMchFzRelationEntity::getTenantId, tenantId)
+ .eq(ThirdMchFzRelationEntity::getMchId, merchant.getId())
+ .eq(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode())
+ .eq(ThirdMchFzRelationEntity::getDelFlag, "0");
+ if (!currentSettingIds.isEmpty()) {
+ stale.notIn(ThirdMchFzRelationEntity::getPaySettingId, currentSettingIds);
+ }
+ stale.set(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.NO.getCode())
+ .set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode());
+ relationMapper.update(null, stale);
+ }
+
+ /**
+ * 校验支付配置当前默认关系已明确绑定。
+ *
+ * @param setting 商户支付配置
+ * @return 已绑定关系;无抽佣比例时为空
+ */
+ @Override
+ public ThirdMchFzRelationEntity requireBound(MchPaySettingVo setting) {
+ if (!isEligible(setting)) {
+ return null;
+ }
+ Long tenantId = setting.getTenantId() == null ? TenantContextHolder.getTenantId() : setting.getTenantId();
+ ThirdMchFzRelationEntity expected = buildRelation(tenantId, setting.getMchId(), setting);
+ ThirdMchFzRelationEntity relation = relationMapper.selectOne(new LambdaQueryWrapper()
+ .eq(ThirdMchFzRelationEntity::getTenantId, tenantId)
+ .eq(ThirdMchFzRelationEntity::getMchId, setting.getMchId())
+ .eq(ThirdMchFzRelationEntity::getPaySettingId, setting.getId())
+ .eq(ThirdMchFzRelationEntity::getPlatformType, expected.getPlatformType())
+ .eq(ThirdMchFzRelationEntity::getChannelAppId, expected.getChannelAppId())
+ .eq(ThirdMchFzRelationEntity::getChannelSubMchId, expected.getChannelSubMchId())
+ .eq(ThirdMchFzRelationEntity::getReceiverType, expected.getReceiverType())
+ .eq(ThirdMchFzRelationEntity::getReceiverAccount, expected.getReceiverAccount())
+ .eq(ThirdMchFzRelationEntity::getRelationScene, DEFAULT_SCENE)
+ .eq(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode())
+ .eq(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BOUND.getCode())
+ .eq(ThirdMchFzRelationEntity::getDelFlag, "0"));
+ if (relation == null) {
+ throw new IllegalStateException("支付平台分佣关系尚未绑定,请稍后重试");
+ }
+ return relation;
+ }
+
+ /**
+ * 执行关系绑定并保存脱敏快照;异常或渠道未明确成功时返回失败,交由任务状态机重试。
+ *
+ * @param relationId 关系主键
+ * @return 关系已绑定
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public boolean bind(Long relationId) {
+ ThirdMchFzRelationEntity relation = relationMapper.selectById(relationId);
+ if (relation == null || !"0".equals(relation.getDelFlag())) {
+ return false;
+ }
+ if (ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(relation.getStatus())) {
+ return true;
+ }
+ relationMapper.update(null, new LambdaUpdateWrapper()
+ .eq(ThirdMchFzRelationEntity::getId, relationId)
+ .in(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode(),
+ ThirdMchFzRelationStatusEnum.BIND_FAILED.getCode())
+ .set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BINDING)
+ .set(ThirdMchFzRelationEntity::getLastQueryTime, LocalDateTime.now()));
+ relation = relationMapper.selectById(relationId);
+ if (relation == null || !ThirdMchFzRelationStatusEnum.BINDING.getCode().equals(relation.getStatus())) {
+ return ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(relation == null ? null : relation.getStatus());
+ }
+ try {
+ if (WECHAT_TYPE.equals(relation.getPlatformType())) {
+ return bindWechat(relation);
+ }
+ if (ALIPAY_TYPE.equals(relation.getPlatformType())) {
+ return bindAlipay(relation);
+ }
+ return markFailed(relation, "UNSUPPORTED_PLATFORM", "不支持的支付平台");
+ } catch (Exception exception) {
+ log.warn("绑定商户平台分佣关系异常,relationId:{},type:{},exceptionType:{}", relationId,
+ relation.getPlatformType(), exception.getClass().getSimpleName());
+ return markFailed(relation, "NETWORK_OR_SYSTEM", exception.getMessage());
+ }
+ }
+
+ /** 构建关系事实。 */
+ private ThirdMchFzRelationEntity buildRelation(Long tenantId, MchManageVo merchant, MchPaySettingVo setting) {
+ ThirdMchFzRelationEntity result = buildRelation(tenantId, setting.getMchId(), setting);
+ result.setReceiverName("浙江资安信信息科技有限公司");
+ return result;
+ }
+
+ /** 按支付配置解析渠道关系身份和平台默认接收方。 */
+ private ThirdMchFzRelationEntity buildRelation(Long tenantId, Long mchId, MchPaySettingVo setting) {
+ if (tenantId == null || mchId == null || setting == null || setting.getId() == null) {
+ throw new IllegalArgumentException("商户支付配置关系参数不完整");
+ }
+ ThirdMchFzRelationEntity result = new ThirdMchFzRelationEntity();
+ result.setId(IdWorker.getId());
+ result.setTenantId(tenantId);
+ result.setMchId(mchId);
+ result.setPaySettingId(setting.getId());
+ result.setPlatformType(setting.getPlatformType());
+ result.setRelationScene(DEFAULT_SCENE);
+ result.setIsDefault(YesNoEnum.YES.getCode());
+ result.setStatus(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode());
+ result.setDelFlag("0");
+ result.setCreateTime(LocalDateTime.now());
+ result.setUpdateTime(result.getCreateTime());
+ if (WECHAT_TYPE.equals(setting.getPlatformType())) {
+ AppPlusPayConfig.WechatConfig config = requireWechatConfig();
+ MchPaySettingEntity.WechatPaySetting paySetting = parseWechat(setting);
+ result.setChannelAppId(config.getAppPlatformAppId());
+ result.setChannelSubMchId(paySetting.getSubMchid());
+ result.setReceiverType(ReceiverType.MERCHANT_ID.name());
+ result.setReceiverAccount(config.getMerchantId());
+ result.setChannelRelationType(ReceiverRelationType.SERVICE_PROVIDER.name());
+ result.setOutRequestNo("wxrel_" + setting.getId() + "_" + paySetting.getSubMchid());
+ result.setBindReq(JSONUtil.createObj().set("platform", "WECHAT").set("appid", config.getAppPlatformAppId())
+ .set("subMchid", paySetting.getSubMchid()).set("receiverType", ReceiverType.MERCHANT_ID.name())
+ .set("receiverAccount", mask(paySetting(config.getMerchantId()))).toString());
+ } else if (ALIPAY_TYPE.equals(setting.getPlatformType())) {
+ AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(setting);
+ MchPaySettingEntity.AliPaySetting paySetting = parseAliPay(setting);
+ String receiverType = StrUtil.blankToDefault(config.getRoyaltyTransInType(), "userId");
+ String receiverAccount = StrUtil.blankToDefault(config.getRoyaltyTransIn(), "");
+ if (StrUtil.isBlank(receiverAccount)) {
+ throw new IllegalStateException("平台支付宝分佣接收账号未配置");
+ }
+ if (!List.of("userId", "loginName").contains(receiverType)) {
+ throw new IllegalStateException("支付宝分佣接收方类型只支持userId或loginName");
+ }
+ result.setChannelAppId(config.getAppId());
+ result.setChannelSubMchId(paySetting.getSubMchid());
+ result.setAccountKey(paySetting.getAccountKey());
+ result.setReceiverType(receiverType);
+ result.setReceiverAccount(receiverAccount);
+ result.setReceiverName(config.getRoyaltyTransInName());
+ result.setOutRequestNo("alirel_" + setting.getId() + "_" + paySetting.getSubMchid());
+ result.setBindReq(JSONUtil.createObj().set("platform", "ALIPAY").set("appId", config.getAppId())
+ .set("smid", paySetting.getSubMchid()).set("receiverType", receiverType)
+ .set("receiverAccount", mask(receiverAccount)).set("appAuthTokenConfigured", StrUtil.isNotBlank(paySetting.getAppAuthToken())).toString());
+ } else {
+ throw new IllegalArgumentException("不支持的支付平台关系");
+ }
+ return result;
+ }
+
+ /** 判断支付配置是否需要平台分佣关系。 */
+ private boolean isEligible(MchPaySettingVo setting) {
+ return setting != null && setting.getId() != null && YesNoEnum.YES.getCode().equals(setting.getEnable())
+ && List.of(WECHAT_TYPE, ALIPAY_TYPE).contains(setting.getPlatformType())
+ && setting.getFzRatio() != null && setting.getFzRatio() > 0 && setting.getFzRatio() <= 100;
+ }
+
+ /** 微信关系绑定。 */
+ private boolean bindWechat(ThirdMchFzRelationEntity relation) {
+ AddReceiverRequest request = new AddReceiverRequest();
+ request.setAppid(relation.getChannelAppId());
+ request.setSubMchid(relation.getChannelSubMchId());
+ request.setType(ReceiverType.MERCHANT_ID);
+ request.setAccount(relation.getReceiverAccount());
+ request.setName(StrUtil.blankToDefault(relation.getReceiverName(), "浙江资安信信息科技有限公司"));
+ request.setRelationType(ReceiverRelationType.SERVICE_PROVIDER);
+ AddReceiverResponse response = profitsharingService.addReceiver(request);
+ if (response == null) {
+ return markFailed(relation, "EMPTY_RESPONSE", "微信未返回关系绑定结果");
+ }
+ return markBound(relation, JSONUtil.toJsonStr(response), response.getAccount());
+ }
+
+ /** 支付宝关系绑定。 */
+ private boolean bindAlipay(ThirdMchFzRelationEntity relation) throws Exception {
+ MchPaySettingEntity.AliPaySetting paySetting = new MchPaySettingEntity.AliPaySetting();
+ paySetting.setAccountKey(relation.getAccountKey());
+ AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(paySetting);
+ AlipayTradeRoyaltyRelationBindModel model = new AlipayTradeRoyaltyRelationBindModel();
+ model.setOutRequestNo(relation.getOutRequestNo());
+ RoyaltyEntity receiver = new RoyaltyEntity();
+ receiver.setType(relation.getReceiverType());
+ if ("loginName".equals(relation.getReceiverType())) {
+ receiver.setLoginName(relation.getReceiverAccount());
+ } else {
+ receiver.setAccount(relation.getReceiverAccount());
+ }
+ receiver.setName(relation.getReceiverName());
+ receiver.setMemo("平台默认分佣关系");
+ model.setReceiverList(List.of(receiver));
+ AlipayTradeRoyaltyRelationBindRequest request = new AlipayTradeRoyaltyRelationBindRequest();
+ request.setBizModel(model);
+ if (StrUtil.isNotBlank(paySetting.getAppAuthToken())) {
+ request.putOtherTextParam("app_auth_token", paySetting.getAppAuthToken());
+ }
+ AlipayTradeRoyaltyRelationBindResponse response = client(config).execute(request);
+ if (response != null && response.isSuccess()) {
+ return markBound(relation, JSONUtil.toJsonStr(response), response.getResultCode());
+ }
+ // 绑定响应丢失时用相同 out_request_no 查询,避免重复建立关系或误判失败。
+ AlipayTradeRoyaltyRelationBatchqueryResponse queried = queryAlipay(config, relation.getOutRequestNo(), paySetting.getAppAuthToken());
+ if (queried != null && queried.isSuccess() && queried.getReceiverList() != null && !queried.getReceiverList().isEmpty()) {
+ return markBound(relation, JSONUtil.toJsonStr(queried), "QUERY_BOUND");
+ }
+ return markFailed(relation, response == null ? "EMPTY_RESPONSE" : response.getSubCode(),
+ response == null ? "支付宝未返回关系绑定结果" : response.getSubMsg());
+ }
+
+ /** 查询支付宝关系绑定结果。 */
+ private AlipayTradeRoyaltyRelationBatchqueryResponse queryAlipay(AppPlusPayConfig.AliPayConfig config,
+ String outRequestNo, String appAuthToken) throws Exception {
+ AlipayTradeRoyaltyRelationBatchqueryModel model = new AlipayTradeRoyaltyRelationBatchqueryModel();
+ model.setOutRequestNo(outRequestNo);
+ model.setPageNum(1L);
+ model.setPageSize(20L);
+ AlipayTradeRoyaltyRelationBatchqueryRequest request = new AlipayTradeRoyaltyRelationBatchqueryRequest();
+ request.setBizModel(model);
+ if (StrUtil.isNotBlank(appAuthToken)) {
+ request.putOtherTextParam("app_auth_token", appAuthToken);
+ }
+ return client(config).execute(request);
+ }
+
+ /** 动态创建支付宝客户端,命名账号不存在时直接失败。 */
+ private AlipayClient client(AppPlusPayConfig.AliPayConfig config) {
+ if (config == null || StrUtil.hasBlank(config.getAppId(), config.getAppPrivateKey(), config.getAlipayPublicKey())) {
+ throw new IllegalStateException("支付宝平台账号配置不完整");
+ }
+ AlipayConfig sdk = new AlipayConfig();
+ sdk.setServerUrl("https://openapi.alipay.com/gateway.do");
+ sdk.setAppId(config.getAppId());
+ sdk.setPrivateKey(config.getAppPrivateKey());
+ sdk.setFormat("json");
+ sdk.setAlipayPublicKey(config.getAlipayPublicKey());
+ sdk.setCharset("UTF-8");
+ sdk.setSignType("RSA2");
+ return new DefaultAlipayClient(sdk);
+ }
+
+ /** 解析微信支付配置。 */
+ private MchPaySettingEntity.WechatPaySetting parseWechat(MchPaySettingVo setting) {
+ MchPaySettingEntity.WechatPaySetting result = StrUtil.isBlank(setting.getSettingJson()) ? null
+ : JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class);
+ if (result == null || StrUtil.isBlank(result.getSubMchid())) {
+ throw new IllegalStateException("微信子商户号未配置");
+ }
+ return result;
+ }
+
+ /** 解析支付宝直付通配置。 */
+ private MchPaySettingEntity.AliPaySetting parseAliPay(MchPaySettingVo setting) {
+ MchPaySettingEntity.AliPaySetting result = StrUtil.isBlank(setting.getSettingJson()) ? null
+ : JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
+ if (result == null || StrUtil.isBlank(result.getSubMchid())) {
+ throw new IllegalStateException("支付宝直付通二级商户标识未配置");
+ }
+ return result;
+ }
+
+ /** 读取微信平台配置。 */
+ private AppPlusPayConfig.WechatConfig requireWechatConfig() {
+ AppPlusPayConfig.WechatConfig config = appPlusPayConfig.getWechatConfig();
+ if (config == null || StrUtil.hasBlank(config.getAppPlatformAppId(), config.getMerchantId())) {
+ throw new IllegalStateException("微信平台分佣配置不完整");
+ }
+ return config;
+ }
+
+ /** 按 accountKey 读取支付宝平台配置。 */
+ private AppPlusPayConfig.AliPayConfig resolveAliPayConfig(MchPaySettingVo setting) {
+ MchPaySettingEntity.AliPaySetting paySetting = parseAliPay(setting);
+ return resolveAliPayConfig(paySetting);
+ }
+
+ /** 按 accountKey 读取支付宝平台配置。 */
+ private AppPlusPayConfig.AliPayConfig resolveAliPayConfig(MchPaySettingEntity.AliPaySetting setting) {
+ AppPlusPayConfig.AliPayConfig base = appPlusPayConfig.getAliPayConfig();
+ if (base == null) {
+ throw new IllegalStateException("支付宝平台配置不存在");
+ }
+ if (StrUtil.isBlank(setting == null ? null : setting.getAccountKey())) {
+ return base;
+ }
+ AppPlusPayConfig.AliPayConfig named = base.getAccounts() == null ? null : base.getAccounts().get(setting.getAccountKey());
+ if (named == null) {
+ throw new IllegalStateException("支付宝平台账号不存在");
+ }
+ return named;
+ }
+
+ /** 保存绑定成功快照。 */
+ private boolean markBound(ThirdMchFzRelationEntity relation, String response, String externalStatus) {
+ return relationMapper.update(null, new LambdaUpdateWrapper()
+ .eq(ThirdMchFzRelationEntity::getId, relation.getId())
+ .eq(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BINDING.getCode())
+ .set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BOUND.getCode())
+ .set(ThirdMchFzRelationEntity::getBindRes, response)
+ .set(ThirdMchFzRelationEntity::getBindError, null)
+ .set(ThirdMchFzRelationEntity::getLastExternalStatus, externalStatus)
+ .set(ThirdMchFzRelationEntity::getLastQueryTime, LocalDateTime.now())
+ .set(ThirdMchFzRelationEntity::getBoundTime, LocalDateTime.now())) > 0;
+ }
+
+ /** 保存确定性失败或可重试失败快照。 */
+ private boolean markFailed(ThirdMchFzRelationEntity relation, String code, String message) {
+ relationMapper.update(null, new LambdaUpdateWrapper()
+ .eq(ThirdMchFzRelationEntity::getId, relation.getId())
+ .eq(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BINDING.getCode())
+ .set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BIND_FAILED.getCode())
+ .set(ThirdMchFzRelationEntity::getLastExternalStatus, code)
+ .set(ThirdMchFzRelationEntity::getBindError, StrUtil.subPre(StrUtil.blankToDefault(message, code), 1000))
+ .set(ThirdMchFzRelationEntity::getLastQueryTime, LocalDateTime.now()));
+ return false;
+ }
+
+ /** 脱敏账号。 */
+ private String mask(String value) {
+ if (StrUtil.isBlank(value) || value.length() <= 4) {
+ return "****";
+ }
+ return value.substring(0, 2) + "****" + value.substring(value.length() - 2);
+ }
+
+ /** 避免静态检查把敏感值直接写入快照。 */
+ private String paySetting(String value) {
+ return value;
+ }
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPaymentTaskServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPaymentTaskServiceImpl.java
index 66204272..632ef3a0 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPaymentTaskServiceImpl.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPaymentTaskServiceImpl.java
@@ -2,7 +2,6 @@ package com.bsd.pjcloud.thirdparty.service.impl.pay;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
-import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
@@ -99,17 +98,22 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl due = baseMapper.selectDueTasks(type.getCode(), now, batchSize);
int claimed = 0;
- int processing = executor == null ? 0 : executor.getActiveCount();
+ int processing = executor == null ? 0 : executor.getActiveCount() + executor.getQueue().size();
boolean busy = executor == null || executor.getQueue().remainingCapacity() == 0;
for (ThirdPaymentTaskEntity candidate : due) {
String token = UUID.randomUUID().toString().replace("-", "");
@@ -117,7 +121,6 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl executeLeasedTask(leased, dto.getTriggerSource()));
+ claimed++;
} catch (RejectedExecutionException exception) {
baseMapper.release(leased.getId(), token, now.plusSeconds(5), "Worker拒绝任务");
busy = true;
@@ -142,6 +146,11 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl candidates = baseMapper.selectMaterializationCandidates(type.getCode(), now, limit);
@@ -161,9 +170,20 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl {
ThirdPaymentTaskAttemptEntity attempt = new ThirdPaymentTaskAttemptEntity();
attempt.setTaskId(task.getId());
@@ -207,20 +227,37 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl()
- .eq(ThirdPayOrderEntity::getId, child.getId())
- .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType()));
- }
+ if (aggregate == null) {
+ return TaskResult.success("MISSING");
}
- return TaskResult.success("CLOSED");
+ if (StrUtil.isBlank(aggregate.getPayNo())) {
+ // 从未绑定渠道子订单的历史会话没有外部支付事实,可以安全本地关闭。
+ aggregateSessionService.markTerminal(aggregate.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
+ return TaskResult.success("NO_CHANNEL_ORDER");
+ }
+ // 渠道单始终交给唯一 AGGREGATE_SESSION 任务收敛,避免两个任务键并发重复查单或关单。
+ paymentTaskEnqueuer.enqueueAggregateSession(aggregate.getTenantId(), aggregate.getId(),
+ aggregate.getAggregateNo(), aggregate.getPayNo(), LocalDateTime.now());
+ return TaskResult.success("DELEGATED_TO_AGGREGATE_SESSION");
case RECONCILE:
return reconcilePayOrder(task);
default:
@@ -387,6 +422,25 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl 0;
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml
index 61e63c34..9a013c7a 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml
@@ -74,7 +74,7 @@
DATE_ADD(COALESCE(p.pay_time, p.update_time, #{now}), INTERVAL 30 SECOND) AS next_run_time
FROM third_pay_order p
WHERE p.del_flag = '0' AND p.status = '20' AND p.enable_fz = '1'
- AND p.pay_no IS NOT NULL AND p.pay_time IS NOT NULL
+ AND p.pay_no IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM third_fz_order f
WHERE f.tenant_id = p.tenant_id AND f.pay_no = p.pay_no AND f.del_flag = '0')
@@ -106,7 +106,7 @@
JOIN third_pay_order p ON p.tenant_id = f.tenant_id AND p.pay_no = f.pay_no AND p.del_flag = '0'
WHERE f.del_flag = '0' AND f.status = '10' AND f.fz_no IS NOT NULL
AND p.pay_platform_type = '20' AND p.status = '20'
- AND p.pay_time IS NOT NULL AND p.pay_time <= DATE_SUB(#{now}, INTERVAL 30 SECOND)
+ AND COALESCE(p.pay_time, p.update_time, #{now}) <= DATE_SUB(#{now}, INTERVAL 30 SECOND)
AND (JSON_UNQUOTE(JSON_EXTRACT(COALESCE(f.create_res, '{}'), '$.settleConfirm.status')) IS NULL
OR JSON_UNQUOTE(JSON_EXTRACT(COALESCE(f.create_res, '{}'), '$.settleConfirm.status')) <> 'SUCCESS')
AND NOT EXISTS (SELECT 1 FROM third_payment_task t WHERE t.tenant_id = f.tenant_id
@@ -268,6 +268,13 @@
AND next_run_time <= #{now} AND del_flag = '0'
+
+ UPDATE third_payment_task
+ SET lease_expire_time = #{expireTime}, update_time = #{now}
+ WHERE id = #{id} AND status = 'PROCESSING' AND lease_token = #{token}
+ AND lease_owner = #{owner} AND del_flag = '0'
+
+
UPDATE third_payment_task
SET status = 'RETRY', next_run_time = #{nextRunTime}, lease_owner = NULL, lease_token = NULL,
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJobTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJobTest.java
index 2de19b7c..c1160fa8 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJobTest.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJobTest.java
@@ -3,12 +3,9 @@ package com.bsd.pjcloud.thirdparty.job;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum;
-import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
-import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
-import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayPollingCoordinator;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
@@ -22,8 +19,6 @@ import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyNoInteractions;
-import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when;
/**
@@ -39,17 +34,11 @@ class ThirdAggregateOrderPayJobTest {
private ThirdAggregatePaySessionService aggregatePaySessionService;
/**
- * 聚合支付编排服务 Mock,用于确认历史关闭路径不再调用支付平台处理流程。
+ * 聚合支付编排服务 Mock,用于确认历史记录继续交由可信查单路径处理。
*/
@Mock
private ThirdAggregateOrderPayService aggregateOrderPayService;
- /**
- * 第三方子订单服务 Mock;无子订单会话关闭时不应访问该服务。
- */
- @Mock
- private ThirdPayOrderService thirdPayOrderService;
-
/**
* 自适应轮询协调器 Mock,用于允许本轮补偿扫描。
*/
@@ -71,10 +60,10 @@ class ThirdAggregateOrderPayJobTest {
}
/**
- * 验证超过一小时且没有子订单的会话直接标记关闭,不再进入第三方平台查单流程。
+ * 验证超过一小时且没有渠道单的会话仍委托业务编排服务,由业务层决定本地终态。
*/
@Test
- void compensationShouldCloseHistoricalSessionWithoutCallingPaymentPlatform() {
+ void compensationShouldDelegateHistoricalSessionWithoutChannelOrder() {
ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity();
session.setId(201L);
session.setTenantId(66L);
@@ -85,36 +74,27 @@ class ThirdAggregateOrderPayJobTest {
job.handleExpiredSessionCompensation();
- verify(aggregatePaySessionService).markTerminal(
- session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
+ verify(aggregateOrderPayService).processPendingSession(session.getId());
verify(pollingCoordinator).finishAggregateCompensationPolling(true);
- verifyNoInteractions(aggregateOrderPayService, thirdPayOrderService);
}
/**
- * 验证历史子订单即使已保存支付成功事实,也只关闭聚合会话而不执行历史状态恢复。
+ * 验证历史会话已绑定渠道单时不会在 Job 中盲关,而是委托编排服务核对渠道事实。
*/
@Test
- void compensationShouldNotRecoverHistoricalSessionFromSuccessfulChildOrder() {
+ void compensationShouldDelegateHistoricalSessionWithChannelOrder() {
ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity();
session.setId(202L);
session.setTenantId(66L);
session.setPayNo("historical-child-pay-no");
session.setStatus(ThirdAggregatePaySessionStatusEnum.EXPIRED.getType());
session.setCreateTime(LocalDateTime.now().minusHours(2));
- ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity();
- payOrder.setId(302L);
- payOrder.setPayNo(session.getPayNo());
- payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType());
when(pollingCoordinator.tryStartAggregateCompensationPolling()).thenReturn(true);
when(aggregatePaySessionService.list(any(Wrapper.class))).thenReturn(List.of(session), List.of());
- when(thirdPayOrderService.getByPayNo(session.getPayNo())).thenReturn(payOrder);
job.handleExpiredSessionCompensation();
- verify(aggregatePaySessionService).markTerminal(
- session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
- verify(aggregatePaySessionService, never()).syncFromPayOrder(any());
- verifyNoInteractions(aggregateOrderPayService);
+ verify(aggregateOrderPayService).processPendingSession(session.getId());
+ verify(pollingCoordinator).finishAggregateCompensationPolling(true);
}
}
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
index 2d130447..44c603b6 100644
--- 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
@@ -7,6 +7,8 @@ import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
/**
* 支付任务 Mapper 原子条件契约测试,防止维护 SQL 时移除跨实例安全所需的状态或租约约束。
@@ -16,9 +18,15 @@ class ThirdPaymentTaskMapperSqlContractTest {
/** MyBatis 支付任务映射资源路径。 */
private static final String MAPPER_RESOURCE = "mapper/ThirdPaymentTaskMapper.xml";
+ /** 支付任务表与高频业务扫描索引迁移相对项目根目录的路径。 */
+ private static final String PAYMENT_TASK_MIGRATION = "db/20260824_add_third_payment_task.sql";
+
/** 测试启动时读取的 Mapper XML。 */
private static String mapperXml;
+ /** 测试启动时读取的支付任务数据库迁移。 */
+ private static String migrationSql;
+
/**
* 从测试类路径读取本轮实际参与编译的 Mapper XML。
*
@@ -31,6 +39,7 @@ class ThirdPaymentTaskMapperSqlContractTest {
Assertions.assertNotNull(input, "支付任务 Mapper XML 必须进入运行时类路径");
mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
+ migrationSql = readProjectFile(PAYMENT_TASK_MIGRATION);
}
/**
@@ -68,6 +77,30 @@ class ThirdPaymentTaskMapperSqlContractTest {
() -> Assertions.assertTrue(sql.contains("status = 'RETRY'")));
}
+ /**
+ * 验证任务离开本地队列时必须按原租约令牌和 Worker 所有者续租,失效队列项不能访问支付渠道。
+ */
+ @Test
+ void leaseRenewalShouldRequireCurrentTokenAndOwner() {
+ String sql = statement("update", "renewLease");
+ Assertions.assertAll(
+ () -> Assertions.assertTrue(sql.contains("status = 'PROCESSING'")),
+ () -> Assertions.assertTrue(sql.contains("lease_token = #{token}")),
+ () -> Assertions.assertTrue(sql.contains("lease_owner = #{owner}")));
+ }
+
+ /**
+ * 验证高频物化扫描具备与实际谓词和排序方向匹配的业务源表索引,避免百万级数据量下周期全表扫描。
+ */
+ @Test
+ void migrationShouldCoverHighFrequencyBusinessScans() {
+ Assertions.assertAll(
+ () -> Assertions.assertTrue(migrationSql.contains("`idx_third_pay_order_task_scan` (`status`, `del_flag`, `pay_platform_type`, `update_time`, `id`)")),
+ () -> Assertions.assertTrue(migrationSql.contains("`idx_third_fz_order_task_scan` (`status`, `del_flag`, `update_time`, `id`)")),
+ () -> Assertions.assertTrue(migrationSql.contains("`idx_third_aggregate_task_scan` (`status`, `del_flag`, `update_time`, `id`)")),
+ () -> Assertions.assertTrue(migrationSql.contains("`idx_third_aggregate_close_scan` (`status`, `del_flag`, `create_time`, `id`)")));
+ }
+
/**
* 提取指定 MyBatis 语句片段。
*
@@ -82,4 +115,23 @@ class ThirdPaymentTaskMapperSqlContractTest {
Assertions.assertTrue(start >= 0 && end > start, "缺少 MyBatis 语句:" + id);
return mapperXml.substring(start, end);
}
+
+ /**
+ * 从当前 Maven 或 IDE 工作目录向父目录查找项目文件,兼容聚合构建和模块内单测两种启动方式。
+ *
+ * @param relativePath 相对后端项目根目录的文件路径
+ * @return UTF-8 文件内容
+ * @throws IOException 文件读取失败或未找到后端项目根目录时抛出
+ */
+ private static String readProjectFile(String relativePath) throws IOException {
+ Path current = Path.of("").toAbsolutePath();
+ while (current != null) {
+ Path candidate = current.resolve(relativePath);
+ if (Files.isRegularFile(candidate)) {
+ return Files.readString(candidate, StandardCharsets.UTF_8);
+ }
+ current = current.getParent();
+ }
+ throw new IOException("未找到项目文件:" + relativePath);
+ }
}
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
index ca01a067..0d2d22e1 100644
--- 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
@@ -72,4 +72,24 @@ class ThirdPaymentTaskEnqueuerTest {
() -> Assertions.assertEquals(201L, task.getFzOrderId()),
() -> Assertions.assertEquals(110, task.getPriority()));
}
+
+ /**
+ * 验证历史聚合关闭任务物化的会话任务沿用唯一聚合号,不产生第二个渠道处理任务键。
+ */
+ @Test
+ void enqueueAggregateSessionShouldUseStableAggregateKey() {
+ enqueuer.enqueueAggregateSession(9L, 301L, "aggregate-1001", "pay-1001", null);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ThirdPaymentTaskEntity.class);
+ verify(taskMapper).insertIgnore(captor.capture());
+ ThirdPaymentTaskEntity task = captor.getValue();
+ Assertions.assertAll(
+ () -> Assertions.assertEquals("AGGREGATE_SESSION:aggregate-1001", task.getTaskKey()),
+ () -> Assertions.assertEquals(ThirdPaymentTaskTypeEnum.AGGREGATE_SESSION.getCode(), task.getTaskType()),
+ () -> Assertions.assertEquals(301L, task.getAggregateSessionId()),
+ () -> Assertions.assertEquals("aggregate-1001", task.getAggregateNo()),
+ () -> Assertions.assertEquals("pay-1001", task.getPayNo()),
+ () -> Assertions.assertEquals(105, task.getPriority()),
+ () -> Assertions.assertNotNull(task.getNextRunTime()));
+ }
}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java
index 242ec831..03928341 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java
@@ -159,6 +159,27 @@ class ThirdAggregateAliPayServiceImplTest {
Assertions.assertFalse(orderCaptor.getValue().getPayRep().contains("alipay-open-id"));
}
+ /**
+ * 验证商户没有配置授权 Token 时请求中完全不发送 app_auth_token,且不降级为另一套支付流程。
+ *
+ * @throws Exception Mock 支付宝客户端声明的受检异常
+ */
+ @Test
+ void createPayShouldOmitEmptyAppAuthToken() throws Exception {
+ paySetting.setSettingJson(JSONUtil.toJsonStr(new MchPaySettingEntity.AliPaySetting()));
+ AlipayTradeCreateResponse response = new AlipayTradeCreateResponse();
+ response.setCode("10000");
+ response.setTradeNo("2026000000000003");
+ when(alipayClient.execute(any(AlipayTradeCreateRequest.class))).thenReturn(response);
+ when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(true);
+
+ service.createPay(context, paySetting, new ThirdAggregateAlipayOauthIdentity("alipay-open-id", null));
+
+ ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class);
+ verify(alipayClient).execute(requestCaptor.capture());
+ Assertions.assertFalse(requestCaptor.getValue().getTextParams().containsKey("app_auth_token"));
+ }
+
/**
* 验证平台下单成功但本地子订单保存失败时立即发起撤销,避免形成孤立可支付订单。
*
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java
index 28074eaa..23d23279 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java
@@ -25,6 +25,8 @@ import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
+import com.alipay.api.response.AlipayTradeCancelResponse;
+import com.alipay.api.response.AlipayTradeQueryResponse;
import org.jasypt.encryption.StringEncryptor;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
@@ -148,7 +150,7 @@ class ThirdAggregateOrderPayServiceImplTest {
lenient().when(remoteTenantService.list()).thenReturn(R.ok(List.of(tenant)));
request = new ThirdAggregateOrderCodePayDto();
- request.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY);
+ request.setBizType(ThirdPayOrderBizTypeEnum.XFJ);
request.setBizId("order-1001");
request.setMchId(88L);
request.setAmount(1234);
@@ -234,10 +236,10 @@ class ThirdAggregateOrderPayServiceImplTest {
}
/**
- * 验证第三方查单异常不能让已到期会话永久停留在五秒高频轮询队列。
+ * 验证第三方查单异常必须保留原会话状态,由任务表使用同一任务键退避重试。
*/
@Test
- void expiredSessionShouldBecomeExpiredWhenAlipayQueryFails() {
+ void expiredSessionShouldRemainPendingWhenAlipayQueryFails() {
ThirdAggregatePaySessionEntity session = reusableSession();
session.setStatus(ThirdAggregatePaySessionStatusEnum.PAYING.getType());
session.setSelectedPlatform(AppPlusPlatformTypeEnum.ALI_PAY.getType());
@@ -256,14 +258,16 @@ class ThirdAggregateOrderPayServiceImplTest {
Assertions.assertThrows(RuntimeException.class, () -> service.processPendingSession(session.getId()));
- verify(aggregatePaySessionService).markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.EXPIRED);
+ verify(aggregatePaySessionService, never()).markTerminal(any(), any());
+ verify(aggregateAliPayService).query(payOrder.getPayNo(), payOrder.getMchPaySettingJson());
+ verify(aggregateAliPayService, never()).cancel(anyString(), anyString());
}
/**
- * 验证超过一小时的会话从任何处理入口进入时都只做本地关闭,不访问支付宝或微信。
+ * 验证超过一小时但已绑定渠道单的会话仍先查单,并仅在渠道确认撤销后更新本地订单。
*/
@Test
- void historicalSessionShouldCloseLocallyWithoutQueryingPaymentPlatform() {
+ void historicalSessionWithChannelOrderShouldQueryAndCancelPaymentPlatform() {
ThirdAggregatePaySessionEntity session = reusableSession();
session.setStatus(ThirdAggregatePaySessionStatusEnum.PAYING.getType());
session.setSelectedPlatform(AppPlusPlatformTypeEnum.ALI_PAY.getType());
@@ -274,22 +278,34 @@ class ThirdAggregateOrderPayServiceImplTest {
payOrder.setId(102L);
payOrder.setPayNo(session.getPayNo());
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType());
+ payOrder.setMchPaySettingJson("{}");
+ AlipayTradeQueryResponse queryResponse = new AlipayTradeQueryResponse();
+ queryResponse.setCode("10000");
+ queryResponse.setTradeStatus("WAIT_BUYER_PAY");
+ AlipayTradeCancelResponse cancelResponse = new AlipayTradeCancelResponse();
+ cancelResponse.setCode("10000");
+ cancelResponse.setAction("close");
+ cancelResponse.setRetryFlag("N");
when(aggregatePaySessionService.getById(session.getId())).thenReturn(session);
when(thirdPayOrderService.getByPayNo(session.getPayNo())).thenReturn(payOrder);
+ when(aggregateAliPayService.query(payOrder.getPayNo(), payOrder.getMchPaySettingJson()))
+ .thenReturn(queryResponse);
+ when(aggregateAliPayService.cancel(payOrder.getPayNo(), payOrder.getMchPaySettingJson()))
+ .thenReturn(cancelResponse);
service.processPendingSession(session.getId());
- verify(aggregatePaySessionService).markTerminal(
- session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
- verify(thirdPayOrderService).update(any(Wrapper.class));
- verifyNoInteractions(aggregateAliPayService, aggregateWechatPayService);
+ verify(aggregateAliPayService).query(payOrder.getPayNo(), payOrder.getMchPaySettingJson());
+ verify(aggregateAliPayService).cancel(payOrder.getPayNo(), payOrder.getMchPaySettingJson());
+ verify(thirdPayOrderService).update(any(ThirdPayOrderEntity.class), any(Wrapper.class));
+ verifyNoInteractions(aggregateWechatPayService);
}
/**
- * 验证一小时后到达的成功回调只保留子订单事实,不把历史聚合会话恢复为支付成功。
+ * 验证一小时后到达且已经验签的成功回调仍同步真实扣款事实,不受本地超时状态覆盖。
*/
@Test
- void lateSuccessfulCallbackShouldNotRecoverHistoricalSession() {
+ void lateSuccessfulCallbackShouldRecoverHistoricalSession() {
ThirdAggregatePaySessionEntity session = reusableSession();
session.setStatus(ThirdAggregatePaySessionStatusEnum.EXPIRED.getType());
session.setPayNo("late-success-pay-no");
@@ -300,13 +316,11 @@ class ThirdAggregateOrderPayServiceImplTest {
payOrder.setPayNo(session.getPayNo());
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType());
when(aggregatePaySessionService.getByPayNo(payOrder.getPayNo())).thenReturn(session);
- when(thirdPayOrderService.getByPayNo(payOrder.getPayNo())).thenReturn(payOrder);
Assertions.assertTrue(service.syncAggregateChildOrder(payOrder));
- verify(aggregatePaySessionService).markTerminal(
- session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
- verify(aggregatePaySessionService, never()).syncFromPayOrder(any());
+ verify(aggregatePaySessionService).syncFromPayOrder(payOrder);
+ verify(aggregatePaySessionService, never()).markTerminal(any(), any());
verifyNoInteractions(aggregateAliPayService, aggregateWechatPayService);
}
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 21cda0d0..4a2ab590 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
@@ -297,6 +297,22 @@ class ThirdAppPlusAliPayServiceImplTest {
verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class));
}
+ /**
+ * 验证旧版本已经支付成功但没有支付时间的订单仍能物化分账事实,后续等待时间由任务 Worker 安全兜底。
+ */
+ @Test
+ void ensureDirectPayFzOrderShouldRecoverHistoricalOrderWithoutPayTime() {
+ ThirdPayOrderEntity payOrder = paidDirectPayOrder();
+ payOrder.setPayTime(null);
+
+ ThirdFzOrderEntity result = service.ensureDirectPayFzOrder(payOrder);
+
+ Assertions.assertNotNull(result);
+ Assertions.assertEquals("fz_" + payOrder.getPayNo(), result.getFzNo());
+ Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_UNPAID.getType(), result.getStatus());
+ verify(thirdFzOrderService).save(result);
+ }
+
/**
* 验证已完成平台抽佣的支付宝订单退款携带按退款金额折算的 refund_royalty_parameters。
*/
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPaymentTaskServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPaymentTaskServiceImplTest.java
new file mode 100644
index 00000000..a3c45539
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPaymentTaskServiceImplTest.java
@@ -0,0 +1,274 @@
+package com.bsd.pjcloud.thirdparty.service.impl.pay;
+
+import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum;
+import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
+import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum;
+import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPaymentTaskDispatchDto;
+import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
+import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
+import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPaymentTaskEntity;
+import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskAttemptMapper;
+import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskMapper;
+import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService;
+import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
+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.ThirdPaymentTaskEnqueuer;
+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 java.lang.reflect.Method;
+import java.time.LocalDateTime;
+import java.util.Collections;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * 支付任务 Worker 聚合会话分支测试,重点验证历史任务不会并发重复访问支付渠道。
+ */
+@ExtendWith(MockitoExtension.class)
+class ThirdPaymentTaskServiceImplTest {
+
+ /** 支付任务尝试日志 Mapper Mock,本组测试不执行异步租约入口。 */
+ @Mock
+ private ThirdPaymentTaskAttemptMapper attemptMapper;
+
+ /** 支付任务 Mapper Mock,用于验证分发租约使用 thirdparty 当前时间。 */
+ @Mock
+ private ThirdPaymentTaskMapper taskMapper;
+
+ /** 支付订单服务 Mock,用于确认关闭分支不直接读写渠道子订单。 */
+ @Mock
+ private ThirdPayOrderService payOrderService;
+
+ /** 分账订单服务 Mock,本组聚合测试不使用。 */
+ @Mock
+ private ThirdFzOrderService fzOrderService;
+
+ /** App 支付编排服务 Mock,本组聚合测试不使用。 */
+ @Mock
+ private ThirdAppPlusService appPlusService;
+
+ /** 微信分账服务 Mock,本组聚合测试不使用。 */
+ @Mock
+ private ThirdAppPlusWechatFzService wechatFzService;
+
+ /** 支付宝直付通服务 Mock,本组聚合测试不使用。 */
+ @Mock
+ private ThirdAppPlusAliPayService aliPayService;
+
+ /** 聚合会话服务 Mock,用于返回最新会话状态和验证本地终态更新。 */
+ @Mock
+ private ThirdAggregatePaySessionService aggregateSessionService;
+
+ /** 聚合支付编排服务 Mock,用于验证唯一会话任务负责渠道查单。 */
+ @Mock
+ private ThirdAggregateOrderPayService aggregateOrderPayService;
+
+ /** 稳定任务写入器 Mock,用于验证历史关闭任务只物化唯一会话任务。 */
+ @Mock
+ private ThirdPaymentTaskEnqueuer paymentTaskEnqueuer;
+
+ /** 被测支付任务 Worker。 */
+ @InjectMocks
+ private ThirdPaymentTaskServiceImpl service;
+
+ /**
+ * 将继承自 MyBatis-Plus ServiceImpl 的 Mapper 字段替换为 Mock,避免分发测试访问数据库。
+ */
+ @BeforeEach
+ void setUpBaseMapper() {
+ ReflectionTestUtils.setField(service, "baseMapper", taskMapper);
+ }
+
+ /**
+ * 验证已绑定渠道单的历史会话只补建唯一会话任务,不在关闭任务中再次访问渠道。
+ *
+ * @throws Exception 反射调用私有任务分派方法失败时抛出
+ */
+ @Test
+ void aggregateCloseWithChannelOrderShouldDelegateToStableSessionTask() throws Exception {
+ ThirdAggregatePaySessionEntity session = historicalSession("pay-1001");
+ when(aggregateSessionService.getById(session.getId())).thenReturn(session);
+
+ Object result = runTask(task(ThirdPaymentTaskTypeEnum.AGGREGATE_CLOSE, session));
+
+ Assertions.assertTrue(resultSuccess(result));
+ Assertions.assertEquals("DELEGATED_TO_AGGREGATE_SESSION", resultExternalStatus(result));
+ verify(paymentTaskEnqueuer).enqueueAggregateSession(eq(session.getTenantId()), eq(session.getId()),
+ eq(session.getAggregateNo()), eq(session.getPayNo()), any(LocalDateTime.class));
+ verifyNoInteractions(aggregateOrderPayService, payOrderService);
+ verify(aggregateSessionService, never()).markTerminal(any(), any());
+ }
+
+ /**
+ * 验证从未绑定渠道单的历史会话可以本地关闭,且不会创建无意义的渠道查询任务。
+ *
+ * @throws Exception 反射调用私有任务分派方法失败时抛出
+ */
+ @Test
+ void aggregateCloseWithoutChannelOrderShouldCloseLocally() throws Exception {
+ ThirdAggregatePaySessionEntity session = historicalSession(null);
+ when(aggregateSessionService.getById(session.getId())).thenReturn(session);
+
+ Object result = runTask(task(ThirdPaymentTaskTypeEnum.AGGREGATE_CLOSE, session));
+
+ Assertions.assertTrue(resultSuccess(result));
+ Assertions.assertEquals("NO_CHANNEL_ORDER", resultExternalStatus(result));
+ verify(aggregateSessionService).markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
+ verifyNoInteractions(aggregateOrderPayService, payOrderService, paymentTaskEnqueuer);
+ }
+
+ /**
+ * 验证聚合渠道状态尚未收敛时保持同一会话任务重试,不能把 EXPIRED 误当最终成功。
+ *
+ * @throws Exception 反射调用私有任务分派方法失败时抛出
+ */
+ @Test
+ void aggregateSessionWithUnknownChannelStateShouldRetry() throws Exception {
+ ThirdAggregatePaySessionEntity session = historicalSession("pay-1001");
+ when(aggregateSessionService.getById(session.getId())).thenReturn(session);
+
+ Object result = runTask(task(ThirdPaymentTaskTypeEnum.AGGREGATE_SESSION, session));
+
+ Assertions.assertFalse(resultSuccess(result));
+ Assertions.assertEquals("PROCESSING", resultExternalStatus(result));
+ verify(aggregateOrderPayService).processPendingSession(session.getId());
+ }
+
+ /**
+ * 验证同步创建响应晚于任务领取时,PAY_CREATE 立即完成并只补建稳定 PAY_QUERY,避免两类任务重复查单。
+ *
+ * @throws Exception 反射调用私有任务分派方法失败时抛出
+ */
+ @Test
+ void acceptedPayCreateShouldDelegateToStablePayQuery() throws Exception {
+ ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity();
+ payOrder.setId(301L);
+ payOrder.setTenantId(9L);
+ payOrder.setPayNo("pay-create-1001");
+ payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType());
+ payOrder.setPayRes("{\"accepted\":true}");
+ when(payOrderService.getById(payOrder.getId())).thenReturn(payOrder);
+ ThirdPaymentTaskEntity task = new ThirdPaymentTaskEntity();
+ task.setTaskType(ThirdPaymentTaskTypeEnum.PAY_CREATE.getCode());
+ task.setPayOrderId(payOrder.getId());
+
+ Object result = runTask(task);
+
+ Assertions.assertTrue(resultSuccess(result));
+ Assertions.assertEquals("CREATE_ACCEPTED", resultExternalStatus(result));
+ verify(paymentTaskEnqueuer).enqueuePayQuery(payOrder.getTenantId(), payOrder.getId(), payOrder.getPayNo());
+ verifyNoInteractions(appPlusService);
+ }
+
+ /**
+ * 验证 Quartz misfire 产生的陈旧触发时间不会让 thirdparty 创建已经过期的执行租约。
+ */
+ @Test
+ void dispatchShouldUseReceiverClockInsteadOfStaleQuartzTriggerTime() {
+ ThirdPaymentTaskDispatchDto dto = new ThirdPaymentTaskDispatchDto();
+ dto.setTaskType(ThirdPaymentTaskTypeEnum.PAY_QUERY.getCode());
+ dto.setBatchSize(100);
+ dto.setTriggerTime(LocalDateTime.now().minusHours(1));
+ when(taskMapper.selectMaterializationCandidates(eq(ThirdPaymentTaskTypeEnum.PAY_QUERY.getCode()),
+ any(LocalDateTime.class), eq(100))).thenReturn(Collections.emptyList());
+ when(taskMapper.selectDueTasks(eq(ThirdPaymentTaskTypeEnum.PAY_QUERY.getCode()),
+ any(LocalDateTime.class), eq(100))).thenReturn(Collections.emptyList());
+ LocalDateTime beforeDispatch = LocalDateTime.now();
+
+ service.dispatch(dto);
+
+ ArgumentCaptor nowCaptor = ArgumentCaptor.forClass(LocalDateTime.class);
+ verify(taskMapper).recoverExpiredLeases(eq(ThirdPaymentTaskTypeEnum.PAY_QUERY.getCode()), nowCaptor.capture());
+ Assertions.assertFalse(nowCaptor.getValue().isBefore(beforeDispatch));
+ Assertions.assertTrue(nowCaptor.getValue().isAfter(dto.getTriggerTime()));
+ }
+
+ /**
+ * 构造历史聚合会话,状态保持 EXPIRED 以模拟服务重启后的恢复记录。
+ *
+ * @param payNo 渠道支付单号;为空表示从未创建渠道子订单
+ * @return 带稳定租户和聚合会话号的历史记录
+ */
+ private ThirdAggregatePaySessionEntity historicalSession(String payNo) {
+ ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity();
+ session.setId(201L);
+ session.setTenantId(9L);
+ session.setAggregateNo("aggregate-1001");
+ session.setPayNo(payNo);
+ session.setStatus(ThirdAggregatePaySessionStatusEnum.EXPIRED.getType());
+ return session;
+ }
+
+ /**
+ * 构造关联指定聚合会话的任务事实。
+ *
+ * @param type 待验证的任务类型
+ * @param session 任务关联聚合会话
+ * @return 可直接传入 Worker 分派方法的任务
+ */
+ private ThirdPaymentTaskEntity task(ThirdPaymentTaskTypeEnum type, ThirdAggregatePaySessionEntity session) {
+ ThirdPaymentTaskEntity task = new ThirdPaymentTaskEntity();
+ task.setTaskType(type.getCode());
+ task.setTenantId(session.getTenantId());
+ task.setAggregateSessionId(session.getId());
+ task.setAggregateNo(session.getAggregateNo());
+ task.setPayNo(session.getPayNo());
+ return task;
+ }
+
+ /**
+ * 反射调用 Worker 私有分派方法,使测试只覆盖状态映射而不启动线程池和数据库租约。
+ *
+ * @param task 待执行任务
+ * @return Worker 内部任务结果记录
+ * @throws Exception 方法查找、访问或执行失败时抛出
+ */
+ private Object runTask(ThirdPaymentTaskEntity task) throws Exception {
+ Method method = ThirdPaymentTaskServiceImpl.class.getDeclaredMethod("runTask", ThirdPaymentTaskEntity.class);
+ method.setAccessible(true);
+ return method.invoke(service, task);
+ }
+
+ /**
+ * 读取内部任务结果的成功标识。
+ *
+ * @param result Worker 内部任务结果记录
+ * @return 当前任务是否完成
+ * @throws Exception 结果访问器不可用时抛出
+ */
+ private boolean resultSuccess(Object result) throws Exception {
+ Method method = result.getClass().getDeclaredMethod("success");
+ method.setAccessible(true);
+ return (boolean) method.invoke(result);
+ }
+
+ /**
+ * 读取内部任务结果保存的外部状态。
+ *
+ * @param result Worker 内部任务结果记录
+ * @return 最近渠道或本地状态
+ * @throws Exception 结果访问器不可用时抛出
+ */
+ private String resultExternalStatus(Object result) throws Exception {
+ Method method = result.getClass().getDeclaredMethod("externalStatus");
+ method.setAccessible(true);
+ return (String) method.invoke(result);
+ }
+}
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
index 66c52c9c..6b78ab46 100644
--- 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
@@ -17,15 +17,16 @@ import java.util.List;
@Configuration
@RequiredArgsConstructor
public class ThirdPaymentQuartzInitializer implements InitializingBean {
- /** 支付固定任务所属 Quartz 分组。 */
+ /** 支付固定任务所属 Quartz 分组,作为代码托管任务与后台可编辑任务的隔离边界。 */
private static final String GROUP = "THIRD_PAYMENT_SYSTEM";
- /** 每轮默认最大领取数量。 */
+ /** 单个任务类型每轮最大领取数量,限制一次 Feign 分发对 thirdparty Worker 队列的压力。 */
private static final int DEFAULT_BATCH_SIZE = 100;
- /** Quartz调度器。 */
+ /** 共享数据库持久化的 Quartz 调度器,由集群负责单次触发与故障接管。 */
private final Scheduler scheduler;
/**
* 支付任务与固定触发间隔;同一 JobKey 在 Quartz 集群中同一时刻只会由一个节点执行。
+ * 间隔单位为秒,misfire 恢复时只推进后续触发,不突发补跑所有错过次数。
*/
private static final List SCHEDULES = List.of(
new PaymentSchedule(ThirdPaymentTaskTypeEnum.PAY_CREATE, 5),
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
index 90143324..d6e15f88 100644
--- 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
@@ -17,9 +17,9 @@ import java.time.LocalDateTime;
@Slf4j
@DisallowConcurrentExecution
public class ThirdPaymentDispatchJob implements Job {
- /** JobDataMap中的任务类型键。 */
+ /** JobDataMap 中的任务类型键,值为任务类型持久化编码。 */
public static final String TASK_TYPE_KEY = "taskType";
- /** JobDataMap中的批量大小键。 */
+ /** JobDataMap 中的批量大小键,限制 thirdparty 单次可领取任务数量。 */
public static final String BATCH_SIZE_KEY = "batchSize";
/** thirdparty内部任务分发 Feign 客户端,由 Quartz JobFactory 注入。 */
@Autowired
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionRemarkDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionRemarkDto.java
new file mode 100644
index 00000000..74ef1f7a
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionRemarkDto.java
@@ -0,0 +1,28 @@
+package com.bsd.pjcloud.wallet.api.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 钱包流水备注修改参数,只允许后台修改备注,不承载金额、余额和业务关联等不可变流水信息。
+ */
+@Data
+@Schema(description = "钱包流水备注修改参数")
+public class WalletTransactionRemarkDto {
+
+ /**
+ * 需要修改备注的钱包流水编号,由钱包流水列表返回。
+ */
+ @NotNull(message = "钱包流水编号不能为空")
+ @Schema(description = "钱包流水编号", requiredMode = Schema.RequiredMode.REQUIRED)
+ private Long id;
+
+ /**
+ * 流水业务备注;允许传空以清除原备注,非空内容最多 256 个字符。
+ */
+ @Size(max = 256, message = "流水备注不能超过256个字符")
+ @Schema(description = "流水备注,最多256个字符")
+ private String remark;
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java
index 7e0529e8..21c5504d 100644
--- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -84,6 +85,13 @@ public class WalletTransactionEntity extends Model {
@Schema(description = "财务统计标签,多个字典值使用英文逗号分隔")
private String tags;
+ /**
+ * 流水备注,由后台操作人员补充业务说明;历史流水和未填写场景允许为空,数据库最多保存 256 个字符。
+ */
+ @Size(max = 256, message = "流水备注不能超过256个字符")
+ @Schema(description = "流水备注,最多256个字符")
+ private String remark;
+
/**
* 创建人
*/
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java
index d7fd3033..55e41a33 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java
@@ -14,6 +14,7 @@ import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum;
import com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto;
+import com.bsd.pjcloud.wallet.api.dto.WalletTransactionRemarkDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionSumAndCountDto;
import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity;
import com.bsd.pjcloud.wallet.api.vo.WalletCzTotalVo;
@@ -160,10 +161,24 @@ public class WalletTransactionController {
@PutMapping("/update-balance")
@Operation(summary = "更新会员用户余额")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_update_balance')")
- public R updateWalletBalance(@RequestBody WalletTransactionDto dto) {
+ public R updateWalletBalance(@Valid @RequestBody WalletTransactionDto dto) {
return R.ok(walletTransactionService.updateWalletBalance(dto));
}
+ /**
+ * 修改单笔钱包流水的业务备注,不开放金额、余额、业务类型等流水核心字段的变更。
+ *
+ * @param dto 流水编号和新备注,备注为空时清除原值
+ * @return 更新到当前租户有效流水时返回 {@code true}
+ */
+ @PutMapping("/update-remark")
+ @Operation(summary = "修改钱包流水备注")
+ @SysLog("修改钱包流水备注")
+ @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_edit')")
+ public R updateRemark(@Valid @RequestBody WalletTransactionRemarkDto dto) {
+ return R.ok(walletTransactionService.updateRemark(dto));
+ }
+
@Operation(summary = "后台充值分页", description = "后台充值分页")
@PostMapping("/page-cz")
public R pageCz(@ParameterObject Page page, @RequestBody WalletTransactionDto dto) {
@@ -295,7 +310,7 @@ public class WalletTransactionController {
@SysLog("新增会员钱包流水表")
@PostMapping
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_add')")
- public R save(@RequestBody WalletTransactionEntity walletTransaction) {
+ public R save(@Valid @RequestBody WalletTransactionEntity walletTransaction) {
return R.ok(walletTransactionService.save(walletTransaction));
}
@@ -303,7 +318,7 @@ public class WalletTransactionController {
@SysLog("修改会员钱包流水表")
@PutMapping
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_edit')")
- public R updateById(@RequestBody WalletTransactionEntity walletTransaction) {
+ public R updateById(@Valid @RequestBody WalletTransactionEntity walletTransaction) {
return R.ok(walletTransactionService.updateById(walletTransaction));
}
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 99bf9c56..6dbf4961 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
@@ -6,6 +6,7 @@ import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum;
import com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto;
+import com.bsd.pjcloud.wallet.api.dto.WalletTransactionRemarkDto;
import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity;
import com.bsd.pjcloud.wallet.api.vo.WalletCzTotalVo;
import com.bsd.pjcloud.wallet.api.vo.WalletTypeTotalVo;
@@ -121,6 +122,27 @@ public interface WalletTransactionService extends IServicelambdaUpdate()
+ .eq(WalletTransactionEntity::getId, dto.getId())
+ .set(WalletTransactionEntity::getRemark, remark));
+ // 部分 MySQL affected-row 配置会把“值未变化”返回为 0,仅在记录也查不到时才判定失败。
+ if (updated == 0 && baseMapper.selectById(dto.getId()) == null) {
+ throw new RuntimeException("钱包流水不存在或已删除");
+ }
+ return true;
+ }
+
@Override
// @CacheEvict(value = CacheConstants.WALLET_DETAIL, key = "#dto.getWalletId()")
public Boolean updateWalletBalance(WalletTransactionDto dto) {
@@ -763,7 +809,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl
+
@@ -24,6 +25,7 @@
a.price,
a.balance,
a.tags,
+ a.remark,
a.create_by,
a.create_time,
a.update_by,