feat:支付宝分账

This commit is contained in:
dzc committed 2026-08-26 13:10:07 +08:00
1 parent 388ef914fc
commit 14c2acb6c2
62 files changed
+4095 -1403

No files matched your search

-37
View File
@@ -1,37 +0,0 @@
-- 支付宝与微信聚合订单码支付。
-- 聚合会话与实际第三方支付订单分表保存,避免改变已被多个调用方使用的 third_pay_order 契约。
-- 微信统一使用服务商平台公众号配置和各商户既有 sub_mchid,不新增逐商户公众号配置表。
CREATE TABLE `third_aggregate_pay_session`
(
`id` bigint NOT NULL COMMENT '聚合会话主键',
`aggregate_no` varchar(64) NOT NULL COMMENT '聚合支付会话唯一编号',
`access_token_hash` char(64) NOT NULL COMMENT '收银URL随机访问令牌SHA-256摘要',
`access_token_ciphertext` varchar(512) NOT NULL COMMENT '收银URL随机访问令牌Jasypt密文,仅用于幂等复用',
`biz_type` varchar(32) NOT NULL COMMENT '支付业务类型编码,首版为YXFJ_PAY对应编码',
`biz_id` varchar(128) NOT NULL COMMENT '调用方业务订单唯一标识',
`user_id` bigint NOT NULL COMMENT '付款用户ID,匿名消费机订单为-1',
`mch_id` bigint NOT NULL COMMENT '收款商户ID',
`price` int NOT NULL COMMENT '应付金额,单位为人民币分',
`status` varchar(32) NOT NULL COMMENT '聚合会话状态编码',
`selected_platform` varchar(32) DEFAULT NULL COMMENT '首个有效扫码选定的支付平台编码',
`pay_no` varchar(128) DEFAULT NULL COMMENT '绑定的third_pay_order.pay_no',
`payer_identity_hash` char(64) DEFAULT NULL COMMENT '支付宝用户ID或微信服务商公众号sp_openid的SHA-256摘要',
`expire_time` datetime NOT NULL COMMENT '聚合二维码与第三方子订单服务端失效时间',
`resolved_time` datetime DEFAULT NULL COMMENT '会话进入成功、关闭、过期或创建失败终态的时间',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
`update_time` datetime NOT NULL COMMENT '修改时间',
`del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除',
`tenant_id` bigint NOT NULL COMMENT '租户ID',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_third_aggregate_pay_no` (`tenant_id`, `aggregate_no`),
UNIQUE KEY `uk_third_aggregate_access_token` (`tenant_id`, `access_token_hash`),
UNIQUE KEY `uk_third_aggregate_child_pay_no` (`tenant_id`, `pay_no`),
KEY `idx_third_aggregate_biz` (`tenant_id`, `biz_type`, `biz_id`, `create_time`),
KEY `idx_third_aggregate_job` (`status`, `expire_time`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci
COMMENT = '支付宝与微信聚合订单码支付会话';
-153
View File
@@ -1,153 +0,0 @@
-- thirdparty 支付任务事实表与执行尝试表。
-- Quartz 是唯一周期触发入口;任务表只保存可恢复事实、租约和幂等状态。
CREATE TABLE IF NOT EXISTS `third_payment_task`
(
`id` bigint NOT NULL COMMENT '任务主键',
`tenant_id` bigint NOT NULL COMMENT '租户ID',
`task_type` varchar(48) NOT NULL COMMENT '任务类型编码',
`task_key` varchar(192) NOT NULL COMMENT '租户内稳定幂等任务键',
`pay_order_id` bigint DEFAULT NULL COMMENT 'third_pay_order主键',
`fz_order_id` bigint DEFAULT NULL COMMENT 'third_fz_order主键',
`aggregate_session_id` bigint DEFAULT NULL COMMENT 'third_aggregate_pay_session主键',
`pay_no` varchar(128) DEFAULT NULL COMMENT '平台支付单号',
`refund_no` varchar(128) DEFAULT NULL COMMENT '平台退款单号',
`fz_no` varchar(128) DEFAULT NULL COMMENT '平台分账单号',
`aggregate_no` varchar(128) DEFAULT NULL COMMENT '聚合会话单号',
`status` varchar(16) NOT NULL COMMENT '任务状态:READY/PROCESSING/RETRY/SUCCESS/FAILED/ALERT/CANCELLED',
`next_run_time` datetime NOT NULL COMMENT '下一次可执行时间',
`priority` int NOT NULL DEFAULT 0 COMMENT '任务优先级,数值越大越优先',
`lease_owner` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识',
`lease_token` varchar(64) DEFAULT NULL COMMENT '本次执行租约令牌',
`lease_expire_time` datetime DEFAULT NULL COMMENT '租约失效时间',
`attempt_count` int NOT NULL DEFAULT 0 COMMENT '累计执行次数',
`last_external_status` varchar(64) DEFAULT NULL COMMENT '最近一次渠道状态',
`last_error_code` varchar(128) DEFAULT NULL COMMENT '最近一次错误码',
`last_error_message` varchar(1024) DEFAULT NULL COMMENT '最近一次错误摘要',
`finished_time` datetime DEFAULT NULL COMMENT '任务终态时间',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
`update_time` datetime NOT NULL COMMENT '更新时间',
`del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_third_payment_task_tenant_key` (`tenant_id`, `task_key`),
KEY `idx_third_payment_task_due` (`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 = '第三方支付任务事实';
CREATE TABLE IF NOT EXISTS `third_payment_task_attempt`
(
`id` bigint NOT NULL COMMENT '尝试记录主键',
`task_id` bigint NOT NULL COMMENT '任务主键',
`tenant_id` bigint NOT NULL COMMENT '租户ID',
`attempt_no` int NOT NULL COMMENT '任务内尝试序号',
`trigger_source` varchar(32) NOT NULL COMMENT '触发来源:QUARTZ/CALLBACK/SYNC/MANUAL',
`worker_id` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识',
`lease_token` varchar(64) DEFAULT NULL COMMENT '执行租约令牌',
`request_snapshot` text DEFAULT NULL COMMENT '脱敏请求快照',
`response_snapshot` text DEFAULT NULL COMMENT '脱敏响应快照',
`external_status` varchar(64) DEFAULT NULL COMMENT '渠道状态',
`error_code` varchar(128) DEFAULT NULL COMMENT '错误码',
`error_message` varchar(1024) DEFAULT NULL COMMENT '错误摘要',
`started_time` datetime NOT NULL COMMENT '开始时间',
`finished_time` datetime DEFAULT NULL COMMENT '结束时间',
PRIMARY KEY (`id`),
KEY `idx_third_payment_attempt_task` (`task_id`, `attempt_no`),
KEY `idx_third_payment_attempt_tenant_time` (`tenant_id`, `started_time`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务执行尝试';
-- 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。
-111
View File
@@ -1,111 +0,0 @@
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`',
'SELECT 1'
);
PREPARE relation_task_column_stmt FROM @relation_task_column_sql;
EXECUTE relation_task_column_stmt;
DEALLOCATE PREPARE relation_task_column_stmt;
SET @relation_task_index_exists = (
SELECT COUNT(1)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'third_payment_task'
AND INDEX_NAME = 'idx_third_payment_task_relation'
);
SET @relation_task_index_sql = IF(
@relation_task_index_exists = 0,
'ALTER TABLE `third_payment_task` ADD INDEX `idx_third_payment_task_relation` (`tenant_id`, `fz_relation_id`)',
'SELECT 1'
);
PREPARE relation_task_index_stmt FROM @relation_task_index_sql;
EXECUTE relation_task_index_stmt;
DEALLOCATE PREPARE relation_task_index_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`',
'SELECT 1'
);
PREPARE pay_relation_column_stmt FROM @pay_relation_column_sql;
EXECUTE pay_relation_column_stmt;
DEALLOCATE PREPARE pay_relation_column_stmt;
SET @pay_relation_index_exists = (
SELECT COUNT(1)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'third_pay_order'
AND INDEX_NAME = 'idx_third_pay_order_fz_relation'
);
SET @pay_relation_index_sql = IF(
@pay_relation_index_exists = 0,
'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_fz_relation` (`tenant_id`, `fz_relation_id`)',
'SELECT 1'
);
PREPARE pay_relation_index_stmt FROM @pay_relation_index_sql;
EXECUTE pay_relation_index_stmt;
DEALLOCATE PREPARE pay_relation_index_stmt;
+362
View File
@@ -0,0 +1,362 @@
-- thirdparty 支付、聚合支付、任务补偿和商户分账关系统一迁移脚本。
-- 线上只执行本文件;脚本可重复执行,兼容全新数据库和已经执行部分旧迁移的数据库。
-- 本脚本不删除支付任务、订单、分账关系或历史配置数据;仅在迁移核对通过后清理旧配置字段。
USE `pjcloudx_thirdparty`;
-- 一、聚合订单码支付会话
CREATE TABLE IF NOT EXISTS `third_aggregate_pay_session`
(
`id` bigint NOT NULL COMMENT '聚合会话主键',
`aggregate_no` varchar(64) NOT NULL COMMENT '聚合支付会话唯一编号',
`access_token_hash` char(64) NOT NULL COMMENT '收银URL随机访问令牌SHA-256摘要',
`access_token_ciphertext` varchar(512) NOT NULL COMMENT '收银URL随机访问令牌Jasypt密文,仅用于幂等复用',
`biz_type` varchar(32) NOT NULL COMMENT '支付业务类型编码,首版为YXFJ_PAY对应编码',
`biz_id` varchar(128) NOT NULL COMMENT '调用方业务订单唯一标识',
`user_id` bigint NOT NULL COMMENT '付款用户ID,匿名消费机订单为-1',
`mch_id` bigint NOT NULL COMMENT '收款商户ID',
`price` int NOT NULL COMMENT '应付金额,单位为人民币分',
`status` varchar(32) NOT NULL COMMENT '聚合会话状态编码',
`selected_platform` varchar(32) DEFAULT NULL COMMENT '首个有效扫码选定的支付平台编码',
`pay_no` varchar(128) DEFAULT NULL COMMENT '绑定的third_pay_order.pay_no',
`payer_identity_hash` char(64) DEFAULT NULL COMMENT '支付宝用户ID或微信服务商公众号sp_openid的SHA-256摘要',
`expire_time` datetime NOT NULL COMMENT '聚合二维码与第三方子订单服务端失效时间',
`resolved_time` datetime DEFAULT NULL COMMENT '会话进入成功、关闭、过期或创建失败终态的时间',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
`update_time` datetime NOT NULL COMMENT '修改时间',
`del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除',
`tenant_id` bigint NOT NULL COMMENT '租户ID',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_third_aggregate_pay_no` (`tenant_id`, `aggregate_no`),
UNIQUE KEY `uk_third_aggregate_access_token` (`tenant_id`, `access_token_hash`),
UNIQUE KEY `uk_third_aggregate_child_pay_no` (`tenant_id`, `pay_no`),
KEY `idx_third_aggregate_biz` (`tenant_id`, `biz_type`, `biz_id`, `create_time`),
KEY `idx_third_aggregate_job` (`status`, `expire_time`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci
COMMENT = '支付宝与微信聚合订单码支付会话';
-- 二、Quartz 触发的可恢复支付任务和执行尝试
CREATE TABLE IF NOT EXISTS `third_payment_task`
(
`id` bigint NOT NULL COMMENT '任务主键',
`tenant_id` bigint NOT NULL COMMENT '租户ID',
`task_type` varchar(48) NOT NULL COMMENT '任务类型编码',
`task_key` varchar(192) NOT NULL COMMENT '租户内稳定幂等任务键',
`pay_order_id` bigint DEFAULT NULL COMMENT 'third_pay_order主键',
`fz_order_id` bigint DEFAULT NULL COMMENT 'third_fz_order主键',
`fz_relation_id` bigint DEFAULT NULL COMMENT 'third_mch_fz_relation主键',
`aggregate_session_id` bigint DEFAULT NULL COMMENT 'third_aggregate_pay_session主键',
`pay_no` varchar(128) DEFAULT NULL COMMENT '平台支付单号',
`refund_no` varchar(128) DEFAULT NULL COMMENT '平台退款单号',
`fz_no` varchar(128) DEFAULT NULL COMMENT '平台分账单号',
`aggregate_no` varchar(128) DEFAULT NULL COMMENT '聚合会话单号',
`status` varchar(16) NOT NULL COMMENT '任务状态:READY/PROCESSING/RETRY/SUCCESS/FAILED/ALERT/CANCELLED',
`next_run_time` datetime NOT NULL COMMENT '下一次可执行时间',
`priority` int NOT NULL DEFAULT 0 COMMENT '任务优先级,数值越大越优先',
`lease_owner` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识',
`lease_token` varchar(64) DEFAULT NULL COMMENT '本次执行租约令牌',
`lease_expire_time` datetime DEFAULT NULL COMMENT '租约失效时间',
`attempt_count` int NOT NULL DEFAULT 0 COMMENT '累计执行次数',
`last_external_status` varchar(64) DEFAULT NULL COMMENT '最近一次渠道状态',
`last_error_code` varchar(128) DEFAULT NULL COMMENT '最近一次错误码',
`last_error_message` varchar(1024) DEFAULT NULL COMMENT '最近一次错误摘要',
`finished_time` datetime DEFAULT NULL COMMENT '任务终态时间',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
`update_time` datetime NOT NULL COMMENT '更新时间',
`del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_third_payment_task_tenant_key` (`tenant_id`, `task_key`),
KEY `idx_third_payment_task_due` (`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`),
KEY `idx_third_payment_task_relation` (`tenant_id`, `fz_relation_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务事实';
CREATE TABLE IF NOT EXISTS `third_payment_task_attempt`
(
`id` bigint NOT NULL COMMENT '尝试记录主键',
`task_id` bigint NOT NULL COMMENT '任务主键',
`tenant_id` bigint NOT NULL COMMENT '租户ID',
`attempt_no` int NOT NULL COMMENT '任务内尝试序号',
`trigger_source` varchar(32) NOT NULL COMMENT '触发来源:QUARTZ/CALLBACK/SYNC/MANUAL',
`worker_id` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识',
`lease_token` varchar(64) DEFAULT NULL COMMENT '执行租约令牌',
`request_snapshot` text DEFAULT NULL COMMENT '脱敏请求快照',
`response_snapshot` text DEFAULT NULL COMMENT '脱敏响应快照',
`external_status` varchar(64) DEFAULT NULL COMMENT '渠道状态',
`error_code` varchar(128) DEFAULT NULL COMMENT '错误码',
`error_message` varchar(1024) DEFAULT NULL COMMENT '错误摘要',
`started_time` datetime NOT NULL COMMENT '开始时间',
`finished_time` datetime DEFAULT NULL COMMENT '结束时间',
PRIMARY KEY (`id`),
KEY `idx_third_payment_attempt_task` (`task_id`, `attempt_no`),
KEY `idx_third_payment_attempt_tenant_time` (`tenant_id`, `started_time`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务执行尝试';
-- 三、商户支付分账关系(微信、支付宝共用)
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 '微信子商户号或历史渠道子商户标识快照',
`account_key` varchar(64) DEFAULT NULL COMMENT '支付宝命名平台账号键;默认账号及微信为空',
`app_auth_token` varchar(512) 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 '平台分佣接收方名称快照',
`fz_ratio` decimal(6,3) NOT NULL DEFAULT '0.000' COMMENT '平台抽佣比例,单位百分比,范围0到100,精确到0.001%',
`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 = '商户第三方支付分账关系';
-- 四、旧环境补齐关系引用字段。每个字段独立判断,避免一个缺表/缺列阻断后续迁移。
SET @task_relation_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 @task_relation_column_sql = IF(
@task_relation_column_exists = 0,
'ALTER TABLE `third_payment_task` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''third_mch_fz_relation主键''',
'SELECT 1'
);
PREPARE task_relation_column_stmt FROM @task_relation_column_sql;
EXECUTE task_relation_column_stmt;
DEALLOCATE PREPARE task_relation_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_order_table_exists = (
SELECT COUNT(1) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order'
);
SET @pay_relation_column_sql = IF(
@pay_order_table_exists = 1 AND @pay_relation_column_exists = 0,
'ALTER TABLE `third_pay_order` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''支付创建时绑定的平台分佣关系主键''',
'SELECT 1'
);
PREPARE pay_relation_column_stmt FROM @pay_relation_column_sql;
EXECUTE pay_relation_column_stmt;
DEALLOCATE PREPARE pay_relation_column_stmt;
SET @relation_auth_column_exists = (
SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_mch_fz_relation' AND COLUMN_NAME = 'app_auth_token'
);
SET @relation_auth_column_sql = IF(
@relation_auth_column_exists = 0,
'ALTER TABLE `third_mch_fz_relation` ADD COLUMN `app_auth_token` varchar(512) DEFAULT NULL COMMENT ''支付宝第三方代调用授权令牌;仅服务端渠道调用使用''',
'SELECT 1'
);
PREPARE relation_auth_column_stmt FROM @relation_auth_column_sql;
EXECUTE relation_auth_column_stmt;
DEALLOCATE PREPARE relation_auth_column_stmt;
SET @relation_ratio_column_exists = (
SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_mch_fz_relation' AND COLUMN_NAME = 'fz_ratio'
);
SET @relation_ratio_column_sql = IF(
@relation_ratio_column_exists = 0,
'ALTER TABLE `third_mch_fz_relation` ADD COLUMN `fz_ratio` decimal(6,3) NOT NULL DEFAULT ''0.000'' COMMENT ''平台抽佣比例,单位百分比,范围0到100,精确到0.001%''',
'ALTER TABLE `third_mch_fz_relation` MODIFY COLUMN `fz_ratio` decimal(6,3) NOT NULL DEFAULT ''0.000'' COMMENT ''平台抽佣比例,单位百分比,范围0到100,精确到0.001%'''
);
PREPARE relation_ratio_column_stmt FROM @relation_ratio_column_sql;
EXECUTE relation_ratio_column_stmt;
DEALLOCATE PREPARE relation_ratio_column_stmt;
-- 五、补齐任务和业务扫描索引。索引已存在时不重复创建。
SET @index_ddl = IF(EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_payment_task' AND INDEX_NAME = 'idx_third_payment_task_relation'), 'SELECT 1', 'ALTER TABLE `third_payment_task` ADD INDEX `idx_third_payment_task_relation` (`tenant_id`, `fz_relation_id`)');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
SET @pay_task_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 5 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('status', 'del_flag', 'pay_platform_type', 'update_time', 'id'));
SET @pay_fz_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 5 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('status', 'enable_fz', 'del_flag', 'pay_time', 'id'));
SET @pay_callback_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 5 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('status', 'is_biz_callback', 'del_flag', 'update_time', 'id'));
SET @pay_close_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 5 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('status', 'biz_type', 'del_flag', 'create_time', 'id'));
SET @pay_tenant_no_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 3 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('tenant_id', 'pay_no', 'del_flag'));
SET @pay_relation_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 2 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('tenant_id', 'fz_relation_id'));
SET @fz_task_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 4 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_fz_order' AND COLUMN_NAME IN ('status', 'del_flag', 'update_time', 'id'));
SET @fz_tenant_no_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 3 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_fz_order' AND COLUMN_NAME IN ('tenant_id', 'pay_no', 'del_flag'));
SET @aggregate_task_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 4 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_aggregate_pay_session' AND COLUMN_NAME IN ('status', 'del_flag', 'update_time', 'id'));
SET @aggregate_close_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 4 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_aggregate_pay_session' AND COLUMN_NAME IN ('status', 'del_flag', 'create_time', 'id'));
SET @index_ddl = IF(@pay_task_scan_ready = 1 AND NOT 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'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_task_scan` (`status`, `del_flag`, `pay_platform_type`, `update_time`, `id`)', 'SELECT 1');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(@pay_fz_scan_ready = 1 AND NOT 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'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_fz_scan` (`status`, `enable_fz`, `del_flag`, `pay_time`, `id`)', 'SELECT 1');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(@pay_callback_scan_ready = 1 AND NOT 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'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_callback_scan` (`status`, `is_biz_callback`, `del_flag`, `update_time`, `id`)', 'SELECT 1');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(@pay_close_scan_ready = 1 AND NOT 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'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_close_scan` (`status`, `biz_type`, `del_flag`, `create_time`, `id`)', 'SELECT 1');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(@pay_tenant_no_ready = 1 AND NOT 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'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)', 'SELECT 1');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(@pay_relation_ready = 1 AND NOT 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_relation'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_fz_relation` (`tenant_id`, `fz_relation_id`)', 'SELECT 1');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(@fz_task_scan_ready = 1 AND NOT 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'), 'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_task_scan` (`status`, `del_flag`, `update_time`, `id`)', 'SELECT 1');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(@fz_tenant_no_ready = 1 AND NOT 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'), 'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)', 'SELECT 1');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(@aggregate_task_scan_ready = 1 AND NOT 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'), 'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_task_scan` (`status`, `del_flag`, `update_time`, `id`)', 'SELECT 1');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(@aggregate_close_scan_ready = 1 AND NOT 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'), 'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_close_scan` (`status`, `del_flag`, `create_time`, `id`)', 'SELECT 1');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
-- 六、迁移钱包历史配置。只有关系完整、JSON合法且数据已核对时,才清理旧配置键和旧比例列。
SET @wallet_table_exists = (
SELECT COUNT(1) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'mch_pay_setting'
);
SET @legacy_ratio_column_exists = (
SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'mch_pay_setting' AND COLUMN_NAME = 'fz_ratio'
);
SET @legacy_invalid_json_count = 0;
SET @legacy_invalid_json_sql = IF(
@wallet_table_exists = 1,
'SELECT COUNT(1) INTO @legacy_invalid_json_count FROM `pjcloudx_wallet`.`mch_pay_setting` WHERE platform_type IN (''10'', ''20'') AND setting_json IS NOT NULL AND TRIM(setting_json) <> '''' AND JSON_VALID(setting_json) = 0',
'SELECT 1'
);
PREPARE legacy_invalid_json_stmt FROM @legacy_invalid_json_sql;
EXECUTE legacy_invalid_json_stmt;
DEALLOCATE PREPARE legacy_invalid_json_stmt;
SET @legacy_channel_update_sql = IF(
@wallet_table_exists = 1 AND @legacy_invalid_json_count = 0,
'UPDATE `pjcloudx_thirdparty`.`third_mch_fz_relation` r JOIN `pjcloudx_wallet`.`mch_pay_setting` s ON s.id = r.pay_setting_id AND s.mch_id = r.mch_id AND s.tenant_id = r.tenant_id SET r.channel_sub_mch_id = CASE WHEN NULLIF(TRIM(r.channel_sub_mch_id), '''') IS NULL THEN NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.subMchid''))), ''''), ''null'') ELSE r.channel_sub_mch_id END, r.account_key = CASE WHEN s.platform_type = ''20'' AND NULLIF(TRIM(r.account_key), '''') IS NULL THEN NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.accountKey''))), ''''), ''null'') ELSE r.account_key END, r.app_auth_token = CASE WHEN s.platform_type = ''20'' AND NULLIF(TRIM(r.app_auth_token), '''') IS NULL THEN NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.appAuthToken''))), ''''), ''null'') ELSE r.app_auth_token END WHERE s.platform_type IN (''10'', ''20'') AND JSON_VALID(s.setting_json) = 1 AND r.del_flag = ''0'' AND r.is_default = ''1''',
'SELECT 1'
);
PREPARE legacy_channel_update_stmt FROM @legacy_channel_update_sql;
EXECUTE legacy_channel_update_stmt;
DEALLOCATE PREPARE legacy_channel_update_stmt;
SET @legacy_ratio_update_sql = IF(
@wallet_table_exists = 1 AND @legacy_ratio_column_exists = 1,
'UPDATE `pjcloudx_thirdparty`.`third_mch_fz_relation` r JOIN `pjcloudx_wallet`.`mch_pay_setting` s ON s.id = r.pay_setting_id AND s.mch_id = r.mch_id AND s.tenant_id = r.tenant_id SET r.fz_ratio = LEAST(GREATEST(COALESCE(s.fz_ratio, 0), 0), 100) WHERE s.del_flag = ''0'' AND r.del_flag = ''0'' AND r.is_default = ''1'' AND COALESCE(s.fz_ratio, 0) > 0 AND COALESCE(r.fz_ratio, 0) = 0',
'SELECT 1'
);
PREPARE legacy_ratio_update_stmt FROM @legacy_ratio_update_sql;
EXECUTE legacy_ratio_update_stmt;
DEALLOCATE PREPARE legacy_ratio_update_stmt;
SET @legacy_channel_unmigrated_count = 0;
SET @legacy_channel_unmigrated_sql = IF(
@wallet_table_exists = 1 AND @legacy_invalid_json_count = 0,
'SELECT COUNT(1) INTO @legacy_channel_unmigrated_count FROM `pjcloudx_wallet`.`mch_pay_setting` s WHERE s.del_flag = ''0'' AND s.platform_type IN (''10'', ''20'') AND JSON_VALID(s.setting_json) = 1 AND (NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.subMchid''))), ''''), ''null'') IS NOT NULL OR NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.accountKey''))), ''''), ''null'') IS NOT NULL OR NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.appAuthToken''))), ''''), ''null'') IS NOT NULL) AND NOT EXISTS (SELECT 1 FROM `pjcloudx_thirdparty`.`third_mch_fz_relation` r WHERE r.tenant_id = s.tenant_id AND r.mch_id = s.mch_id AND r.pay_setting_id = s.id AND r.platform_type = s.platform_type AND r.is_default = ''1'' AND r.del_flag = ''0'' AND (NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.subMchid''))), ''''), ''null'') IS NULL OR r.channel_sub_mch_id = NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.subMchid''))), ''''), ''null'')) AND (s.platform_type <> ''20'' OR NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.accountKey''))), ''''), ''null'') IS NULL OR r.account_key = NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.accountKey''))), ''''), ''null'')) AND (s.platform_type <> ''20'' OR NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.appAuthToken''))), ''''), ''null'') IS NULL OR r.app_auth_token = NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.appAuthToken''))), ''''), ''null'')))',
'SELECT 1'
);
PREPARE legacy_channel_unmigrated_stmt FROM @legacy_channel_unmigrated_sql;
EXECUTE legacy_channel_unmigrated_stmt;
DEALLOCATE PREPARE legacy_channel_unmigrated_stmt;
SET @legacy_ratio_unmigrated_count = 0;
SET @legacy_ratio_unmigrated_sql = IF(
@wallet_table_exists = 1 AND @legacy_ratio_column_exists = 1,
'SELECT COUNT(1) INTO @legacy_ratio_unmigrated_count FROM `pjcloudx_wallet`.`mch_pay_setting` s WHERE COALESCE(s.fz_ratio, 0) > 0 AND s.del_flag = ''0'' AND NOT EXISTS (SELECT 1 FROM `pjcloudx_thirdparty`.`third_mch_fz_relation` r WHERE r.tenant_id = s.tenant_id AND r.mch_id = s.mch_id AND r.pay_setting_id = s.id AND r.is_default = ''1'' AND r.fz_ratio > 0 AND r.del_flag = ''0'')',
'SELECT 1'
);
PREPARE legacy_ratio_unmigrated_stmt FROM @legacy_ratio_unmigrated_sql;
EXECUTE legacy_ratio_unmigrated_stmt;
DEALLOCATE PREPARE legacy_ratio_unmigrated_stmt;
SET @legacy_json_cleanup_sql = IF(
@wallet_table_exists = 1 AND @legacy_invalid_json_count = 0 AND @legacy_channel_unmigrated_count = 0 AND @legacy_ratio_unmigrated_count = 0,
'UPDATE `pjcloudx_wallet`.`mch_pay_setting` SET setting_json = CASE WHEN JSON_LENGTH(JSON_REMOVE(setting_json, ''$.subMchid'', ''$.accountKey'', ''$.appAuthToken'')) = 0 THEN NULL ELSE JSON_REMOVE(setting_json, ''$.subMchid'', ''$.accountKey'', ''$.appAuthToken'') END WHERE platform_type IN (''10'', ''20'') AND JSON_VALID(setting_json) = 1',
'SELECT 1'
);
PREPARE legacy_json_cleanup_stmt FROM @legacy_json_cleanup_sql;
EXECUTE legacy_json_cleanup_stmt;
DEALLOCATE PREPARE legacy_json_cleanup_stmt;
SET @drop_legacy_ratio_sql = IF(
@wallet_table_exists = 1 AND @legacy_ratio_column_exists = 1 AND @legacy_invalid_json_count = 0 AND @legacy_channel_unmigrated_count = 0 AND @legacy_ratio_unmigrated_count = 0,
'ALTER TABLE `pjcloudx_wallet`.`mch_pay_setting` DROP COLUMN `fz_ratio`',
'SELECT 1'
);
PREPARE drop_legacy_ratio_stmt FROM @drop_legacy_ratio_sql;
EXECUTE drop_legacy_ratio_stmt;
DEALLOCATE PREPARE drop_legacy_ratio_stmt;
-- 七、输出迁移结果。出现BLOCKED时不要上线新代码,先处理对应数据或数据库前置问题。
SET @required_table_count = (
SELECT COUNT(DISTINCT TABLE_NAME)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('third_aggregate_pay_session', 'third_payment_task',
'third_payment_task_attempt', 'third_mch_fz_relation',
'third_pay_order', 'third_fz_order')
);
SET @required_column_count = (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND ((TABLE_NAME = 'third_mch_fz_relation' AND COLUMN_NAME IN ('app_auth_token', 'fz_ratio'))
OR (TABLE_NAME = 'third_payment_task' AND COLUMN_NAME = 'fz_relation_id')
OR (TABLE_NAME = 'third_pay_order' AND COLUMN_NAME = 'fz_relation_id'))
);
SET @skipped_scan_index_count =
(@pay_task_scan_ready = 0) + (@pay_fz_scan_ready = 0) + (@pay_callback_scan_ready = 0)
+ (@pay_close_scan_ready = 0) + (@pay_tenant_no_ready = 0) + (@pay_relation_ready = 0)
+ (@fz_task_scan_ready = 0) + (@fz_tenant_no_ready = 0)
+ (@aggregate_task_scan_ready = 0) + (@aggregate_close_scan_ready = 0);
SELECT CASE
WHEN @required_table_count <> 6 THEN 'BLOCKED_REQUIRED_THIRD_TABLE_NOT_FOUND'
WHEN @required_column_count <> 4 THEN 'BLOCKED_REQUIRED_THIRD_COLUMN_NOT_FOUND'
WHEN @skipped_scan_index_count > 0 THEN 'BLOCKED_SCAN_INDEX_SOURCE_COLUMN_NOT_FOUND'
WHEN @wallet_table_exists = 0 THEN 'BLOCKED_WALLET_TABLE_NOT_FOUND'
WHEN @legacy_invalid_json_count > 0 THEN 'BLOCKED_INVALID_SETTING_JSON'
WHEN @legacy_channel_unmigrated_count > 0 THEN 'BLOCKED_UNMIGRATED_CHANNEL_CONFIG'
WHEN @legacy_ratio_unmigrated_count > 0 THEN 'BLOCKED_UNMIGRATED_POSITIVE_RATIO'
ELSE 'SUCCESS_THIRD_PAYMENT_SCHEMA_MIGRATED'
END AS migration_status,
@legacy_invalid_json_count AS invalid_setting_json_count,
@legacy_channel_unmigrated_count AS unmigrated_channel_config_count,
@legacy_ratio_unmigrated_count AS unmigrated_positive_ratio_count,
@required_table_count AS required_third_table_count,
@required_column_count AS required_third_column_count,
@skipped_scan_index_count AS skipped_scan_index_count;
SELECT table_name, column_name, column_type
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND ((table_name = 'third_mch_fz_relation' AND column_name IN ('app_auth_token', 'fz_ratio'))
OR (table_name = 'third_payment_task' AND column_name = 'fz_relation_id')
OR (table_name = 'third_pay_order' AND column_name = 'fz_relation_id'))
ORDER BY table_name, ordinal_position;
-3
View File
@@ -164,9 +164,6 @@ CREATE TABLE mch_pay_setting
use_scope varchar(256) NOT NULL COMMENT '使用范围(直接支付,钱包充值)',
setting_json text DEFAULT NULL COMMENT '支付json配置',
fz_ratio int NOT NULL COMMENT '分账比例(最高100%即分给对方商户的%)',
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 '修改人',
@@ -2,7 +2,6 @@ package com.bsd.pjcloud.common.data.datascope;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSON;
import cn.hutool.json.JSONUtil;
@@ -53,19 +52,23 @@ public class DataScopeInnerInterceptor implements DataScopeInterceptor {
List<Long> deptIds = dataScope.getDeptList();
// 1.无数据权限限制,则直接返回 0 条数据
if (CollUtil.isEmpty(deptIds) && StrUtil.isBlank(dataScope.getUsername())) {
boolean hasDeptScope = CollUtil.isNotEmpty(deptIds);
boolean hasUserScope = StrUtil.isNotBlank(dataScope.getUsername())
&& StrUtil.isNotBlank(dataScope.getScopeUserName());
// 1.无可用权限条件时直接返回 0 条数据,避免生成非法的 IN (),也不能因本人字段被禁用而放开查询。
if (!hasDeptScope && !hasUserScope) {
originalSql = String.format("SELECT %s FROM (%s) temp_data_scope WHERE 1 = 2",
dataScope.getFunc().getType(), originalSql);
}
// 2.如果为本人权限 + 部门权限控制
else if (StrUtil.isNotBlank(dataScope.getUsername()) && ObjectUtil.isNotEmpty(dataScope.getScopeUserName()) && CollUtil.isNotEmpty(deptIds)) {
else if (hasUserScope && hasDeptScope) {
String join = CollectionUtil.join(deptIds, ",");
originalSql = String.format("SELECT %s FROM (%s) temp_data_scope WHERE temp_data_scope.%s = '%s' OR temp_data_scope.%s IN (%s)",
dataScope.getFunc().getType(), originalSql, dataScope.getScopeUserName(), dataScope.getUsername(), dataScope.getScopeDeptName(), join);
}
// 3. 如果为本人
else if (StrUtil.isNotBlank(dataScope.getUsername()) && ObjectUtil.isNotEmpty(dataScope.getScopeUserName())) {
else if (hasUserScope) {
originalSql = String.format("SELECT %s FROM (%s) temp_data_scope WHERE temp_data_scope.%s = '%s'",
dataScope.getFunc().getType(), originalSql, dataScope.getScopeUserName(), dataScope.getUsername());
}
@@ -26,7 +26,12 @@ public enum HardAutoRefundTriggerTypeEnum {
/**
* 管理端业务人员对已到期候选订单确认后执行的逐单退款,不复用技术重试权限。
*/
EXPIRED_MANUAL("40", "后台逐单过期退款", false);
EXPIRED_MANUAL("40", "后台逐单过期退款", false),
/**
* 管理端按商户当前配置立即重扫历史订单后触发的自动退款,失败后继续进入既有自动重试状态机。
*/
EXPIRED_RESCAN("50", "后台立即重新扫描", true);
/**
* 数据库存储的稳定触发编码。
@@ -163,11 +163,11 @@ public class HardOrderEntity extends Model<HardOrderEntity> {
@Schema(description = "过期预约退款配置ID快照")
private Long expiredRefundConfigId;
/** 预约订单支付成功时解析出的实际餐段结束时间;为空表示无法安全确定过期资格。 */
@Schema(description = "过期预约餐段实际结束时间快照")
/** 预约下单校验通过时固化的用餐日核销有效期结束时间,固定为 apptUseDate 当天 23:59:59;不受退款开关影响。 */
@Schema(description = "过期预约用餐日核销结束时间快照")
private LocalDateTime expiredRefundUsageEndTime;
/** 实际餐段结束时间加配置延迟后的过期退款触发时间。 */
/** 用餐日核销有效期结束时间加配置延迟后的过期退款触发时间。 */
@Schema(description = "过期预约退款触发时间")
private LocalDateTime expiredRefundDeadline;
@@ -0,0 +1,36 @@
package com.bsd.pjcloud.hard.api.vo.order;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 商户过期预约退款立即重扫结果,供管理端展示本轮任务补建和退款执行数量。
*/
@Data
@Schema(description = "过期预约退款立即重扫结果")
public class HardExpiredRefundRescanResultVo {
/** 本轮读取的已支付、未核销每日菜谱订单数量。 */
@Schema(description = "扫描订单数量")
private Integer scannedOrderCount;
/** 本轮首次补建的过期预约退款任务数量。 */
@Schema(description = "新建任务数量")
private Integer createdTaskCount;
/** 本轮按当前延迟和退库存配置刷新的未尝试任务数量。 */
@Schema(description = "刷新任务数量")
private Integer refreshedTaskCount;
/** 本轮已到达当前触发时间并立即进入退款状态机的任务数量。 */
@Schema(description = "立即执行任务数量")
private Integer executedTaskCount;
/** 本轮立即执行后已完成钱包退款和 Hardware 状态更新的任务数量。 */
@Schema(description = "退款成功任务数量")
private Integer successTaskCount;
/** 因餐段时间无法解析、已有资金尝试或存在冲突业务状态而未同步的订单数量。 */
@Schema(description = "跳过订单数量")
private Integer skippedOrderCount;
}
@@ -10,6 +10,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardAutoRefundTaskVo;
import com.bsd.pjcloud.hard.api.vo.order.HardExpiredRefundRescanResultVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppAutoRefundTaskVo;
import com.bsd.pjcloud.hard.service.HardAfterSaleService;
import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService;
@@ -62,7 +63,7 @@ public class HardAutoRefundTaskController {
* 按当前用户商户数据范围分页查询自动退款任务。
*
* @param page 分页参数
* @param dto 筛选条件
* @param dto 筛选条件
* @return 任务分页
*/
@GetMapping("/page")
@@ -81,24 +82,40 @@ public class HardAutoRefundTaskController {
}
/**
* 查询已到期且仍可整单退款的每日菜谱预约订单;失败或重试耗尽任务不混入候选。
* 按商户详情页传入的精确商户 ID 查询已到期且仍可整单退款的每日菜谱预约订单;失败或重试耗尽任务不混入候选。
*
* @param page 分页参数
* @param dto 候选查询条件,其中商户 ID 必填
* @return 当前商户的待过期退款订单分页
*/
@GetMapping("/expired-candidates")
@Operation(summary = "查询待过期预约退款订单")
@PreAuthorize("@pms.hasPermission('hard_hardAfterSale_view')")
public R<Page<HardAutoRefundTaskVo>> expiredCandidates(@ParameterObject Page<HardAutoRefundTaskEntity> page,
@ParameterObject HardAutoRefundTaskPageDto dto) {
List<Long> scopedMchIds = listScopedMerchantIds();
if (scopedMchIds.isEmpty()) {
Page<HardAutoRefundTaskVo> empty = new Page<>(page.getCurrent(), page.getSize(), 0L);
empty.setRecords(List.of());
return R.ok(empty);
}
dto.setMchIds(scopedMchIds);
public R<Page<HardAutoRefundTaskVo>> expiredCandidates(@ParameterObject Page<HardAutoRefundTaskEntity> page, @ParameterObject HardAutoRefundTaskPageDto dto) {
validateMerchantScope(dto.getMchId());
// 详情接口只能查询一个已校验商户,禁止客户端利用内部批量字段扩大查询范围。
dto.setMchIds(null);
return R.ok(taskService.pageExpiredCandidates(page, dto));
}
/** 管理端确认后逐单执行过期预约整单退款。 */
/**
* 按商户当前配置立即重扫历史过期预约订单;开关关闭时只同步暂停任务,开关开启时执行已到期任务。
*
* @param mchId 商户详情页传入的精确商户 ID
* @return 本轮扫描、任务同步和退款执行数量
*/
@PostMapping("/expired-rescan")
@Operation(summary = "立即重新扫描过期预约退款订单")
@Idempotent(expireTime = 5, info = "正在重新扫描,请勿重复操作")
@PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')")
public R<HardExpiredRefundRescanResultVo> expiredRescan(@RequestParam Long mchId) {
validateMerchantScope(mchId);
return R.ok(taskService.rescanExpiredReserveOrders(mchId, SecurityUtils.getUser().getId()));
}
/**
* 管理端确认后逐单执行过期预约整单退款。
*/
@PostMapping("/{id}/expired-refund")
@Operation(summary = "过期预约订单退款")
@Idempotent(expireTime = 5, info = "正在退款,请勿重复操作")
@@ -168,6 +185,26 @@ public class HardAutoRefundTaskController {
}
}
/**
* 校验单个商户属于当前管理用户数据范围,供商户详情页的精确查询使用。
*
* @param mchId 详情页传入的商户 ID
*/
private void validateMerchantScope(Long mchId) {
if (mchId == null) {
throw new RuntimeException("商户不能为空");
}
MchManageDto query = MchManageDto.builder()
.ids(List.of(mchId))
.typesList(List.of(MerchantTypeEnum.CANTEEN.getType()))
.build();
R<List<MchManageVo>> response = remoteMchService.scopeList(query);
if (response == null || !response.isOk() || response.getData() == null
|| response.getData().stream().noneMatch(item -> item != null && Objects.equals(item.getId(), mchId))) {
throw new RuntimeException("无权访问当前商户");
}
}
/**
* 查询当前管理用户可访问的食堂商户 ID;远程异常拒绝请求,不能降级为全量任务。
*
@@ -115,6 +115,67 @@ public class HardCanteenTotalController {
return R.ok(canteenTotalService.proudctMchQuery(page, dto));
}
/**
* 按销售发生时间分页查询 App 食堂综合销售统计。
*
* <p>线下购买按支付时间统计,预约订单按核销时间统计;返回结构与 PC 菜品销售统计一致,
* 并且只允许查询当前商户管理员可见的食堂。</p>
*
* @param page 按自然日分页的分页参数
* @param dto 统计日期、来源和餐段筛选条件
* @return 日期、餐段和来源多维销售统计分页
*/
@ApiCryptoAes
@Operation(summary = "App食堂综合销售统计", description = "线下按支付时间、预约按核销时间查询日期餐段来源统计")
@PostMapping("/app/proudct-total")
public R<Page<HardCanteenProductTotalVo>> appProductTotal(@ParameterObject Page page,
@RequestBody HardOrderDto dto) {
if (!applyAppCanteenMerchantScope(dto)) {
return R.ok(new Page<HardCanteenProductTotalVo>());
}
return R.ok(canteenTotalService.productTotal(page, dto, null));
}
/**
* 查询 App 食堂综合销售统计中指定日期、餐段和来源的部门明细。
*
* @param page 保留的分页参数,与 PC 同名接口保持调用形式一致
* @param dto 统计日期、来源、餐段和部门筛选条件
* @return 当前用户可见食堂范围内的部门销售汇总
*/
@ApiCryptoAes
@Operation(summary = "App食堂综合销售部门明细", description = "查询日期餐段来源对应的部门销售统计")
@PostMapping("/app/proudct-total-by-dept")
public R<List<HardCanteenProductTotalVo>> appProductTotalByDept(@ParameterObject Page page,
@RequestBody HardOrderDto dto) {
if (!applyAppCanteenMerchantScope(dto)) {
return R.ok(List.of());
}
return R.ok(canteenTotalService.productTotalByDept(page, dto, null));
}
/**
* 将 App 统计查询的食堂条件收敛到当前商户管理员的数据范围。
*
* @param dto 待补全食堂范围的统计请求,已有食堂条件会与可见范围取交集
* @return 是否仍存在可查询的食堂;false 表示调用方应直接返回空数据
*/
private boolean applyAppCanteenMerchantScope(HardOrderDto dto) {
PjcloudUser user = SecurityUtils.getUser();
List<Long> allowedMchIds = remoteMchService
.getSelfMchIdList(user.getId(), List.of(MerchantTypeEnum.CANTEEN)).getData();
if (ObjectUtil.isEmpty(allowedMchIds)) {
dto.setMchIds(List.of());
return false;
}
List<Long> requestedMchIds = dto.getMchIds();
List<Long> scopedMchIds = ObjectUtil.isEmpty(requestedMchIds)
? allowedMchIds
: requestedMchIds.stream().filter(allowedMchIds::contains).distinct().toList();
dto.setMchIds(scopedMchIds);
return ObjectUtil.isNotEmpty(scopedMchIds);
}
/* ************************************* start app start ************************************* */
@Operation(summary = "根据部门 食堂菜品购买统计", description = "根据部门 食堂菜品购买统计")
@PostMapping("/proudct-total-by-dept")
@@ -2,30 +2,23 @@ package com.bsd.pjcloud.hard.controller.order;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.io.resource.ResourceUtil;
import cn.hutool.core.util.ObjectUtil;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.alibaba.excel.write.metadata.fill.FillConfig;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
import com.bsd.pjcloud.common.core.util.BeanUtils;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum;
import com.bsd.pjcloud.common.idempotent.annotation.Idempotent;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
@@ -53,7 +46,6 @@ import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateReserveOrdersVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppScanPayOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateShopOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardTakeOutDeliveryTimeOptionVO;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardOrderExcelVo;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.api.vo.order.HardOnlineCanteenStatsVo;
import com.bsd.pjcloud.hard.service.*;
@@ -74,14 +66,8 @@ import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.IOException;
import java.time.LocalDate;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 订单表
@@ -100,6 +86,9 @@ public class HardOrderController {
private final HardOrderSyncService hardOrderSyncService;
private final HardCanteenTotalService canteenTotalService;
/** 订单导出服务,负责异步任务、分批模板写入和文件中心上传。 */
private final HardOrderExportService hardOrderExportService;
private final HardOrderMapper hardOrderMapper;
private final RemoteMchService mchService;
@@ -214,47 +203,67 @@ public class HardOrderController {
return R.ok(hardwareOrderService.pagejoin(page, dto));
}
@Operation(summary = "app查询食堂订餐的所有商户统计", description = "app查询食堂订餐的所有商户统计")
/**
* 按预约用餐时间统计当前商户管理员可见食堂的预约订单数量。
*
* @param dto 预约订单状态和预约用餐时间范围;创建、支付等其他时间条件会被忽略
* @return 各食堂在预约时间范围内的订单统计
*/
@Operation(summary = "app查询食堂订餐的所有商户统计", description = "按预约用餐时间查询食堂订餐商户统计")
@GetMapping("/app/caneen-rserve-mch-total")
public R appCaneenRserveMchTotal(@ParameterObject HardOrderDto dto) {
DataScope dataScope = null;
PjcloudUser user = SecurityUtils.getUser();
normalizeAppReservationUseDateQuery(dto);
dto.setOrderType(HardOrderTypeEnum.RESERVE.getType());
dto.setTotalIsFindChildren(false);
dto.setApptIdOrApptRuleIdNotNull(true);
return R.ok(canteenTotalService.listByDept(dto, user, dataScope));
}
@Operation(summary = "app查询食堂订餐订单列表", description = "app查询食堂订餐订单列表")
/**
* 按预约用餐时间分页查询当前商户管理员可核销的食堂预约订单。
*
* @param page 分页参数
* @param dto 订单状态、食堂和预约用餐时间范围;创建、支付等其他时间条件会被忽略
* @return 按预约用餐日期倒序排列的预约订单分页
*/
@Operation(summary = "app查询食堂订餐订单列表", description = "按预约用餐时间查询食堂订餐订单列表")
@GetMapping("/app/caneen-rserve-page-join")
public R appCaneenRservePageJoin(@ParameterObject Page page, @ParameterObject HardOrderDto dto) {
normalizeAppReservationUseDateQuery(dto);
dto.setIsFindCarList(true);
dto.setIsFindApptList(true);
dto.setOrderType(HardOrderTypeEnum.RESERVE.getType());
dto.setOrderStatusCodes(List.of(HardOrderStatusEnum.PAY.getType(), HardOrderStatusEnum.CANCEL.getType()));
dto.setApptIdOrApptRuleIdNotNull(true);
DataScope dataScope = null;
// 查询商户
MchManageDto mchManageDto = new MchManageDto();
mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
List<MchManageVo> mchList = mchService.scopeList(mchManageDto).getData();
if (ObjectUtil.isEmpty(mchList)) {
List<Long> mchIdList = mchService.getSelfMchIdList(SecurityUtils.getUser().getId(), List.of(MerchantTypeEnum.CANTEEN)).getData();
if (ObjectUtil.isEmpty(mchIdList)) {
return R.ok(new Page<HardOrderVo>());
}
List<Long> allowedMchIds = mchList.stream().map(MchManageVo::getId).toList();
dto.setMchIds(ObjectUtil.isEmpty(dto.getMchIds())
? allowedMchIds
: dto.getMchIds().stream().filter(allowedMchIds::contains).toList());
dto.setMchIds(ObjectUtil.isEmpty(dto.getMchIds()) ? mchIdList : dto.getMchIds().stream().filter(mchIdList::contains).toList());
if (ObjectUtil.isEmpty(dto.getMchIds())) {
return R.ok(new Page<HardOrderVo>());
}
return R.ok(hardwareOrderService.scopePageList(page, dto, dataScope));
}
/**
* 统一商户核销页面的预约时间口径,避免历史创建时间或支付时间参数与预约日期形成交叉筛选。
*
* @param dto 当前两个核销查询接口接收的订单筛选条件,方法会原地清除非预约日期条件
*/
private void normalizeAppReservationUseDateQuery(HardOrderDto dto) {
dto.setCreateTimeList(null);
dto.setCreateTimeStr(null);
dto.setPayTimeList(null);
dto.setUpdateTimeList(null);
dto.setVerificationTimeList(null);
dto.setStatisticsDateTimeList(null);
}
@Operation(summary = "app查询食堂订单列表", description = "app查询订单列表")
@ApiCryptoAes
@GetMapping("/app/caneen-page-join")
@@ -652,77 +661,30 @@ public class HardOrderController {
return R.ok(hardwareOrderService.shipment(dto));
}
/**
* 提交订单异步分批导出任务。
*
* @param dto 订单页面当前筛选条件;orderIds 非空时仅导出勾选订单
* @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
*/
@Operation(summary = "订单记录异步导出", description = "按订单筛选条件分批查询并流式生成 Excel")
@SysLog("订单记录异步导出")
@PostMapping("/export-task")
@PreAuthorize("@pms.hasPermission('hard_hardwareOrder_export')")
public R<AsyncTaskProgressVO> submitExport(@RequestBody HardOrderDto dto) {
return R.ok(hardOrderExportService.submitExport(dto), "订单导出任务已提交");
}
/**
* 兼容仍使用旧下载方式的订单页面,同步响应内部也由独立导出服务分批生成。
*
* @param response 当前 HTTP 下载响应
* @param dto 订单页面当前筛选条件
*/
@Operation(summary = "订单记录导出", description = "兼容旧页面的同步订单导出")
@GetMapping("/export")
@PreAuthorize("@pms.hasPermission('hard_hardwareOrder_export')")
public void export(HttpServletResponse response, HardOrderDto dto) throws IOException {
DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL);
dataScope.setScopeDeptName("merchant_dept_id");
PjcloudUser user = SecurityUtils.getUser();
CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user);
dto.setMchTypeCodes(hardTypeInfo.getHardTypeList());
// 查询商户和下面的子商户
List<Long> mchDtoIds = dto.getMchIds();
if (ObjectUtil.isNotEmpty(mchDtoIds) && mchDtoIds.size() == 1) {
Long mchId = mchDtoIds.get(0);
List<MchManageVo> mchList = mchService.getByParentId(mchId).getData();
if (ObjectUtil.isNotEmpty(mchList)) {
List<Long> mchIds = mchList.stream().map(MchManageVo::getId).toList();
mchDtoIds.addAll(mchIds);
dto.setMchIds(mchDtoIds);
}
}
List<HardOrderVo> list = hardwareOrderService.scopeList(dto, DataScope.of());
List<HardOrderExcelVo> excelList = BeanUtils.toBean(list, HardOrderExcelVo.class);
try (ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).withTemplate(ResourceUtil.getStream("file/订单交易汇总.xlsx")).build()) {
WriteSheet writeSheet = EasyExcel.writerSheet().build();
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
Map<String, Object> map = new HashMap<String, Object>();
if (ObjectUtil.isNotEmpty(dto.getPayTimeList())) {
map.put("timeSpan", " 到 ");
map.put("startTime", LocalDateTimeUtil.format(dto.getPayTimeList()[0], DatePattern.NORM_DATETIME_PATTERN));
map.put("endTime", LocalDateTimeUtil.format(dto.getPayTimeList()[1], DatePattern.NORM_DATETIME_PATTERN));
}
if (ObjectUtil.isNotEmpty(excelList)) {
BigDecimal totalPriceSum = BigDecimal.ZERO;
BigDecimal extraPriceSum = BigDecimal.ZERO;
BigDecimal payPriceSum = BigDecimal.ZERO;
Long lineNumber = 1L;
for (HardOrderExcelVo v : excelList) {
v.setLineNumber(lineNumber);
lineNumber++;
if (ObjectUtil.isNotEmpty(v.getTotalPrice())) {
totalPriceSum = totalPriceSum.add(BigDecimal.valueOf(v.getTotalPrice()));
v.setTotalPrice(BigDecimal.valueOf(v.getTotalPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue());
}
if (ObjectUtil.isNotEmpty(v.getExtraPrice())) {
extraPriceSum = extraPriceSum.add(BigDecimal.valueOf(v.getExtraPrice()));
v.setExtraPrice(BigDecimal.valueOf(v.getExtraPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue());
}
if (ObjectUtil.isNotEmpty(v.getPayPrice())) {
payPriceSum = payPriceSum.add(BigDecimal.valueOf(v.getPayPrice()));
v.setPayPrice(BigDecimal.valueOf(v.getPayPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue());
}
if (ObjectUtil.isNotEmpty(v.getHourType())) {
HardHourTypeEnum hardHourTypeEnum = HardHourTypeEnum.valueOfByType(v.getHourType());
if (ObjectUtil.isNotEmpty(hardHourTypeEnum)) {
v.setHourType(hardHourTypeEnum.getDescription());
} else {
v.setHourType("");
}
}
if (ObjectUtil.isNotEmpty(v.getPayType())) {
HardPayTypeEnum hardPayTypeEnum = HardPayTypeEnum.valueOfByType(v.getPayType());
if (ObjectUtil.isNotEmpty(hardPayTypeEnum)) {
v.setPayType(hardPayTypeEnum.getDescription());
}
}
}
map.put("totalPriceSum", totalPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
map.put("extraPriceSum", extraPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
map.put("payPriceSum", payPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
}
excelWriter.fill(map, writeSheet);
excelWriter.fill(excelList, fillConfig, writeSheet);
}
public void export(HttpServletResponse response, HardOrderDto dto) {
hardOrderExportService.export(response, dto);
}
}
@@ -5,10 +5,12 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
import java.util.List;
/**
* 自动退款任务 Mapper,任务执行通过主键行锁串行化多实例竞争。
@@ -64,4 +66,12 @@ public interface HardAutoRefundTaskMapper extends PjcloudBaseMapper<HardAutoRefu
Page<HardAutoRefundTaskEntity> pageExpiredCandidates(Page<HardAutoRefundTaskEntity> page,
@Param("dto") HardAutoRefundTaskPageDto dto,
@Param("now") LocalDateTime now);
/**
* 查询指定商户仍处于已支付未核销状态且没有冲突售后的每日菜谱钱包订单,供立即重扫补建任务。
*
* @param mchId 精确商户 ID
* @return 按订单主键升序排列的可重扫订单
*/
List<HardOrderEntity> listExpiredRescanOrders(@Param("mchId") Long mchId);
}
@@ -39,7 +39,7 @@ public interface HardApptReservationService {
* @param apptDate 实际用餐日期
* @param apptRuleId 周期预约规则 ID;动态模式为空
* @param eligibilityDeadline 当前已校验预约窗口的截止时间,使用服务器现有预约时间口径
* @param usageEndDateTime 实际用餐餐段的结束时间;无法从有效餐段唯一解析时为空,禁止用于过期退款
* @param usageEndDateTime 实际用餐日的核销有效期结束时间,固定为该日 23:59:59,供订单固化后重扫使用
*/
record ReservationValidation(HardApptEntity appt, String hourType, LocalDate apptDate, Long apptRuleId,
LocalDateTime eligibilityDeadline, LocalDateTime usageEndDateTime) {
@@ -9,6 +9,7 @@ import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardAutoRefundTaskVo;
import com.bsd.pjcloud.hard.api.vo.order.HardExpiredRefundRescanResultVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppAutoRefundTaskVo;
import java.util.List;
@@ -63,6 +64,15 @@ public interface HardAutoRefundTaskService extends IService<HardAutoRefundTaskEn
/** 查询已到期且仍可整单退款的每日菜谱预约候选任务。 */
Page<HardAutoRefundTaskVo> pageExpiredCandidates(Page<HardAutoRefundTaskEntity> page, HardAutoRefundTaskPageDto dto);
/**
* 按商户当前过期预约配置重扫历史已支付未核销订单,补建或刷新任务并立即执行已到期任务。
*
* @param mchId 需要重扫的商户 ID
* @param operatorUserId 发起重扫的后台用户 ID,用于尝试日志审计
* @return 本轮扫描、任务同步和退款执行数量
*/
HardExpiredRefundRescanResultVo rescanExpiredReserveOrders(Long mchId, Long operatorUserId);
/**
* 查询任务的只读尝试日志,按尝试序号升序返回。
*
@@ -0,0 +1,30 @@
package com.bsd.pjcloud.hard.service;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import jakarta.servlet.http.HttpServletResponse;
/**
* 硬件订单导出服务。
*
* <p>该服务独立承担导出条件冻结、数据权限、分批查询、模板写入、进度上报和文件中心上传,
* 订单查询服务只保留订单业务查询职责。</p>
*/
public interface HardOrderExportService {
/**
* 提交硬件订单异步分批导出任务。
*
* @param dto 订单页面当前筛选条件;orderIds 非空时仅导出勾选订单
* @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
*/
AsyncTaskProgressVO submitExport(HardOrderDto dto);
/**
* 兼容旧页面的同步导出入口,同样按页查询并通过临时文件流式生成工作簿。
*
* @param response 当前 HTTP 下载响应,方法会写入完整 XLSX 文件
* @param dto 订单页面当前筛选条件
*/
void export(HttpServletResponse response, HardOrderDto dto);
}
@@ -358,11 +358,10 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
throw new RuntimeException(availability.unavailableReason());
}
return new ReservationValidation(appt, appt.getHourType(), targetDate, null, availability.endDateTime(),
availability.endDateTime());
resolveUsageEndDateTime(targetDate));
}
private ReservationValidation validateCycleReservation(Long mchId, Long apptRuleId, String hourType,
LocalDate apptDate, LocalDateTime now) {
private ReservationValidation validateCycleReservation(Long mchId, Long apptRuleId, String hourType, LocalDate apptDate, LocalDateTime now) {
if (apptDate == null || HardHourTypeEnum.valueOfByType(hourType) == null) {
throw new RuntimeException("周期预约需要选择预约日期和餐端");
}
@@ -379,33 +378,18 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
throw new RuntimeException(match.availability().unavailableReason());
}
return new ReservationValidation(null, hourType, apptDate, selected.getId(), match.availability().endDateTime(),
resolveCycleUsageEndDateTime(mchId, hourType, apptDate));
resolveUsageEndDateTime(apptDate));
}
/**
* 周期预约的开放窗口结束时间不是餐段结束时间;这里按已启用的实际 hard_appt 餐段解析,
* 无法唯一解析时返回空值,调用方会放弃过期退款快照以避免误退。
* 预约订单的核销有效期按实际用餐日全天计算,统一固化为当天 23:59:59。
* 该口径不读取当前餐段配置,避免预约配置高频调整后影响已创建订单。
*
* @param mchId 订单所属商户 ID
* @param hourType 订单选中的标准餐段编码
* @param apptDate 实际用餐日期
* @return 匹配餐段的实际结束时间;缺失、重复或时间配置异常时返回 {@code null}
* @return 当天 23:59:59;用餐日期为空时返回 {@code null}
*/
private LocalDateTime resolveCycleUsageEndDateTime(Long mchId, String hourType, LocalDate apptDate) {
List<HardApptEntity> candidates = hardApptService.list(new LambdaQueryWrapper<HardApptEntity>()
.eq(HardApptEntity::getMchId, mchId)
.eq(HardApptEntity::getHourType, hourType)
.eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode())
.orderByAsc(HardApptEntity::getId));
if (candidates.size() != 1) {
return null;
}
HardApptEntity appt = candidates.get(0);
HardApptTimeTypeEnum endType = HardApptTimeTypeEnum.valueOfByType(appt.getEndTimeType());
if (endType == null || appt.getEndTime() == null) {
return null;
}
return apptDate.plusDays(endType.getDay()).atTime(appt.getEndTime());
private LocalDateTime resolveUsageEndDateTime(LocalDate apptDate) {
return apptDate == null ? null : apptDate.atTime(23, 59, 59);
}
private CycleMatch matchCycleRule(HardApptCycleRuleVo rule, String hourType,
@@ -19,12 +19,14 @@ import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundAttemptResultEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardSaleStatusEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardAutoRefundTaskVo;
import com.bsd.pjcloud.hard.api.vo.order.HardExpiredRefundRescanResultVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppAutoRefundTaskVo;
import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleMapper;
import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundAttemptLogMapper;
@@ -108,6 +110,21 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
*/
private static final String SUPPORTED_ORDER_TYPE = "2";
/** 过期预约退款延迟允许的最小小时数。 */
private static final int MIN_EXPIRED_RESERVE_DELAY_HOURS = 1;
/** 过期预约退款延迟允许的最大小时数。 */
private static final int MAX_EXPIRED_RESERVE_DELAY_HOURS = 168;
/** 重扫单个订单时未同步任务,结果计数保持为零。 */
private static final int RESCAN_SYNC_SKIPPED = 0;
/** 重扫单个订单时新建任务,结果计数为一。 */
private static final int RESCAN_SYNC_CREATED = 1;
/** 重扫单个订单时刷新未尝试任务,结果计数为二。 */
private static final int RESCAN_SYNC_REFRESHED = 2;
/**
* 尝试日志 Mapper,用于保存每次自动、人工和开关跳过记录。
*/
@@ -118,7 +135,9 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
*/
private final HardAfterSaleMapper afterSaleMapper;
/** 订单 Mapper,用于过期候选列表补充预约日期和餐段快照。 */
/**
* 订单 Mapper,用于过期候选列表补充预约日期和餐段快照。
*/
private final com.bsd.pjcloud.hard.mapper.order.HardOrderMapper hardOrderMapper;
/**
@@ -154,7 +173,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 在售后申请事务内创建任务。配置服务异常、开关关闭或快照不完整时返回空并保留人工审核流程。
*
* @param order 已锁定的原订单
* @param order 已锁定的原订单
* @param afterSale 新建售后单
* @return 已创建任务;不符合免审条件时返回 {@code null}
*/
@@ -267,8 +286,8 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 使用订单 Redis 锁包裹尝试和失败状态落库,避免事务回滚释放行锁后被另一实例插入同一尝试序号。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
*/
@Override
@@ -311,14 +330,14 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 在创建系统售后前复核到期时间、任务状态和当前配置,避免开关已关闭时提前占用订单售后。
*
* @param taskId 过期预约任务主键
* @param triggerType 触发来源,人工业务退款不受当前开关控制
* @param taskId 过期预约任务主键
* @param triggerType 触发来源,人工业务退款不受当前开关控制
* @param operatorUserId 人工操作用户 ID,自动触发为空
* @return 当前允许准备系统售后时返回 {@code true}
*/
@Transactional(rollbackFor = Exception.class)
public boolean canPrepareExpiredReserveTask(Long taskId, HardAutoRefundTriggerTypeEnum triggerType,
Long operatorUserId) {
Long operatorUserId) {
HardAutoRefundTaskEntity task = baseMapper.selectByIdForUpdate(taskId);
if (!canExecute(task, triggerType)
|| !Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())) {
@@ -363,8 +382,8 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
* 在单个 Hardware 事务中锁任务、复核配置、调用 Wallet 并原子完成售后和任务状态。
* 资金调用异常会使本事务全部回滚,外层随后用独立事务写失败日志。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
*/
@Transactional(rollbackFor = Exception.class)
@@ -420,9 +439,9 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
* 记录一次成功完成的 Wallet 调用并原子推进售后和任务状态。恢复已到账记录时不再消耗自动尝试额度,
* 因为真正的资金调用已在此前回滚的 Hardware 事务中发生。
*
* @param task 已锁定任务
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
* @param task 已锁定任务
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
* @param countAutoAttempt 是否把本次调用计入八次自动资金尝试额度
*/
private void completeAttempt(HardAutoRefundTaskEntity task, HardAutoRefundTriggerTypeEnum triggerType,
@@ -478,10 +497,10 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 实际尝试失败后再次读取当前开关;若失败由关闭竞态导致则转为暂停且不消耗自动额度。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
* @param exception 本次执行异常
* @param exception 本次执行异常
*/
private void handleAttemptFailure(Long taskId, HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId,
Exception exception) {
@@ -517,13 +536,13 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
* 在当前订单 Redis 锁内探测 Wallet 已到账记录,并通过独立 Hardware 事务恢复本地状态。
* 此方法直接调用事务代理,不递归进入 {@link #execute(Long, HardAutoRefundTriggerTypeEnum, Long)},避免重复获取同一订单锁。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
* @return Wallet 已存在退款记录并完成本地恢复时返回 {@code true}
*/
private boolean tryRecoverPersistedWalletRefund(Long taskId, HardAutoRefundTriggerTypeEnum triggerType,
Long operatorUserId) {
Long operatorUserId) {
HardAutoRefundTaskEntity task = getById(taskId);
if (!hasPersistedWalletRefund(task)) {
return false;
@@ -536,8 +555,8 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
* 在独立 Hardware 事务中锁定任务并恢复 Wallet 已到账结果,不重新获取订单 Redis 锁。
* Wallet 的退款接口仍会按售后单 ID 严格校验钱包和金额后返回原记录。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
*/
@Transactional(rollbackFor = Exception.class)
@@ -553,13 +572,13 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 在当前 Hardware 事务中恢复已持久化的钱包退款。调用方已持有任务行锁,恢复过程不消耗自动额度。
*
* @param task 已锁定任务
* @param triggerType 触发来源
* @param task 已锁定任务
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
* @return Wallet 已存在退款记录并完成本地恢复时返回 {@code true}
*/
private boolean recoverPersistedWalletRefund(HardAutoRefundTaskEntity task,
HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId) {
HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId) {
if (!hasPersistedWalletRefund(task)) {
return false;
}
@@ -594,10 +613,10 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 独立事务记录一次真实资金尝试失败,并根据自动次数计算固定重试时间或耗尽状态。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
* @param errorMessage 已截断的非敏感错误信息
* @param errorMessage 已截断的非敏感错误信息
*/
@Transactional(rollbackFor = Exception.class)
public void recordFailureInTransaction(Long taskId, HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId,
@@ -658,8 +677,8 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 已发起调用后发现开关关闭时写一条跳过日志并暂停,不增加自动尝试次数。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
*/
@Transactional(rollbackFor = Exception.class)
@@ -675,7 +694,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 更新配置不可用暂停状态并设置下一轮复查时间。
*
* @param task 已锁定任务
* @param task 已锁定任务
* @param reason 暂停原因
*/
private void pauseConfigUnavailable(HardAutoRefundTaskEntity task, String reason) {
@@ -688,8 +707,8 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 首次遇到关闭开关时记录一次跳过审计,持续暂停扫描不会重复增加日志。
*
* @param task 已锁定任务
* @param triggerType 触发来源
* @param task 已锁定任务
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
*/
private void pauseForDisabledSwitch(HardAutoRefundTaskEntity task, HardAutoRefundTriggerTypeEnum triggerType,
@@ -721,7 +740,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 判断当前任务状态和触发来源是否允许进入资金尝试。
*
* @param task 已锁定任务
* @param task 已锁定任务
* @param triggerType 触发来源
* @return 允许执行时返回 {@code true}
*/
@@ -755,7 +774,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 查询当前配置并统一校验 Feign 业务响应,异常由调用方区分受理降级或任务暂停。
*
* @param mchId 商户 ID
* @param mchId 商户 ID
* @param orderType 硬件订单类型编码
* @return 当前配置;未配置时返回 {@code null}
*/
@@ -779,7 +798,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
*
* @param configId 快照配置主键
* @param ruleType 快照规则名称
* @param current 当前 Wallet 配置
* @param current 当前 Wallet 配置
* @return 配置身份和规则一致时返回 {@code true}
*/
private boolean matchesSnapshot(Long configId, String ruleType, MchAutoRefundConfigVo current) {
@@ -788,7 +807,9 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
&& Objects.equals(current.getOrderType(), SUPPORTED_ORDER_TYPE);
}
/** 校验过期任务只匹配创建时配置身份和过期退款开关,不读取或覆盖订单延迟快照。 */
/**
* 校验过期任务只匹配创建时配置身份和过期退款开关,不读取或覆盖订单延迟快照。
*/
private boolean matchesExpiredSnapshot(HardAutoRefundTaskEntity task, MchAutoRefundConfigVo current) {
return current != null && Objects.equals(current.getId(), task.getConfigId())
&& Objects.equals(current.getOrderType(), SUPPORTED_ORDER_TYPE);
@@ -812,7 +833,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
* 分页查询任务并尽力补充关联展示数据;远程展示信息异常不会影响技术人员查看任务本体。
*
* @param page 分页参数
* @param dto 服务端已注入商户数据范围的筛选条件
* @param dto 服务端已注入商户数据范围的筛选条件
* @return 任务分页
*/
@Override
@@ -922,7 +943,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 仅允许重试耗尽任务进入人工额外尝试,重复点击仍由任务行锁复查状态。
*
* @param taskId 任务主键
* @param taskId 任务主键
* @param operatorUserId 当前管理用户 ID
*/
@Override
@@ -941,7 +962,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
* 管理端逐单执行过期预约业务退款,候选任务不受技术重试状态机权限限制。
* 状态机为保留失败日志会吸收资金异常,因此执行后必须复查最终状态,避免管理端把失败提示成成功。
*
* @param taskId 过期预约任务主键
* @param taskId 过期预约任务主键
* @param operatorUserId 当前业务操作用户 ID
*/
@Override
@@ -965,10 +986,11 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
}
}
/** 查询已到期、未失败耗尽且仍可整单退款的任务,失败任务留在技术任务页处理。 */
/**
* 查询已到期、未失败耗尽且仍可整单退款的任务,失败任务留在技术任务页处理。
*/
@Override
public Page<HardAutoRefundTaskVo> pageExpiredCandidates(Page<HardAutoRefundTaskEntity> page,
HardAutoRefundTaskPageDto dto) {
public Page<HardAutoRefundTaskVo> pageExpiredCandidates(Page<HardAutoRefundTaskEntity> page, HardAutoRefundTaskPageDto dto) {
LocalDateTime now = LocalDateTime.now();
Page<HardAutoRefundTaskEntity> entityPage = baseMapper.pageExpiredCandidates(page, dto, now);
List<HardAutoRefundTaskVo> records = BeanUtils.toBean(entityPage.getRecords(), HardAutoRefundTaskVo.class);
@@ -981,7 +1003,193 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
return result;
}
/** 批量补充过期候选订单日期和餐段,避免把配置变更后的规则重新解析为退款时间。 */
/**
* 按商户当前过期预约配置重扫历史订单,补建或刷新未尝试任务,并立即执行已经到期的任务。
* 资金动作仍统一经过现有订单锁、售后校验、Wallet 幂等和自动重试状态机;开关关闭时只同步暂停任务,绝不调用退款。
*
* @param mchId 精确商户 ID
* @param operatorUserId 发起重扫的后台用户 ID,用于退款尝试审计
* @return 本轮扫描、任务同步和退款执行数量
*/
@Override
public HardExpiredRefundRescanResultVo rescanExpiredReserveOrders(Long mchId, Long operatorUserId) {
if (mchId == null) {
throw new RuntimeException("商户不能为空");
}
MchAutoRefundConfigVo config = loadCurrentConfig(mchId, SUPPORTED_ORDER_TYPE);
if (config == null || config.getId() == null || !Objects.equals(config.getMchId(), mchId) || !Objects.equals(config.getOrderType(), SUPPORTED_ORDER_TYPE)) {
throw new RuntimeException("当前商户未配置过期预约退款规则");
}
int delayHours = config.getExpiredReserveRefundDelayHours() == null ? 0 : config.getExpiredReserveRefundDelayHours();
if (delayHours < MIN_EXPIRED_RESERVE_DELAY_HOURS || delayHours > MAX_EXPIRED_RESERVE_DELAY_HOURS) {
throw new RuntimeException("过期预约退款延迟必须在1到168小时之间");
}
boolean enabled = Objects.equals(config.getExpiredReserveRefundEnabled(), YesNoEnum.YES.getCode());
String restoreInventory = Objects.equals(config.getExpiredReserveRefundRestoreInventory(), YesNoEnum.YES.getCode()) ? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode();
List<HardOrderEntity> orders = baseMapper.listExpiredRescanOrders(mchId);
HardExpiredRefundRescanResultVo result = new HardExpiredRefundRescanResultVo();
result.setScannedOrderCount(orders == null ? 0 : orders.size());
result.setCreatedTaskCount(0);
result.setRefreshedTaskCount(0);
result.setExecutedTaskCount(0);
result.setSuccessTaskCount(0);
result.setSkippedOrderCount(0);
if (orders == null || orders.isEmpty()) {
return result;
}
LocalDateTime now = LocalDateTime.now();
for (HardOrderEntity order : orders) {
boolean taskSynced = false;
try {
LocalDateTime usageEndTime = resolveExpiredRefundUsageEndTimeSnapshot(order);
if (usageEndTime == null) {
result.setSkippedOrderCount(result.getSkippedOrderCount() + 1);
log.warn("|{}|立即重扫跳过缺少可靠订单时间快照的订单,orderId:{}", TAG, order.getId());
continue;
}
LocalDateTime triggerTime = usageEndTime.plusHours(delayHours);
LockInfo syncLock = lockTemplate.lock(REFUND_ORDER_LOCK_PREFIX + order.getId(),
REFUND_ORDER_LOCK_EXPIRE_MILLIS, REFUND_ORDER_LOCK_WAIT_MILLIS);
if (syncLock == null) {
result.setSkippedOrderCount(result.getSkippedOrderCount() + 1);
continue;
}
int syncResult;
try {
syncResult = getSelf().syncExpiredRescanTask(order, config, triggerTime, restoreInventory, enabled);
} finally {
try {
lockTemplate.releaseLock(syncLock);
} catch (Exception exception) {
log.warn("|{}|释放立即重扫订单锁失败,orderId:{},exceptionType:{}", TAG, order.getId(),
exception.getClass().getSimpleName());
}
}
if (syncResult == RESCAN_SYNC_CREATED) {
result.setCreatedTaskCount(result.getCreatedTaskCount() + 1);
} else if (syncResult == RESCAN_SYNC_REFRESHED) {
result.setRefreshedTaskCount(result.getRefreshedTaskCount() + 1);
} else {
result.setSkippedOrderCount(result.getSkippedOrderCount() + 1);
continue;
}
taskSynced = true;
if (enabled && !triggerTime.isAfter(now)) {
HardAutoRefundTaskEntity task = baseMapper.selectOne(new LambdaQueryWrapper<HardAutoRefundTaskEntity>()
.eq(HardAutoRefundTaskEntity::getOrderId, order.getId())
.eq(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()));
if (task != null && Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.PENDING.getStatus())) {
result.setExecutedTaskCount(result.getExecutedTaskCount() + 1);
execute(task.getId(), HardAutoRefundTriggerTypeEnum.EXPIRED_RESCAN, operatorUserId);
HardAutoRefundTaskEntity completed = getById(task.getId());
if (completed != null && Objects.equals(completed.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) {
result.setSuccessTaskCount(result.getSuccessTaskCount() + 1);
}
}
}
} catch (Exception exception) {
if (!taskSynced) {
result.setSkippedOrderCount(result.getSkippedOrderCount() + 1);
}
log.warn("|{}|立即重扫订单失败,orderId:{},exceptionType:{}", TAG, order.getId(),
exception.getClass().getSimpleName());
}
}
return result;
}
/**
* 解析订单自身保存的过期退款时间快照。新订单优先使用专用时间字段;历史订单仅使用订单上的预约日期
* 兜底为当天 23:59:59,绝不读取当前预约配置或旧的自动退款截止时间。
*
* @param order 待重扫的已支付预约订单
* @return 可信的核销有效期结束时间;订单没有预约日期时返回 {@code null}
*/
private LocalDateTime resolveExpiredRefundUsageEndTimeSnapshot(HardOrderEntity order) {
if (order == null) {
return null;
}
if (order.getExpiredRefundUsageEndTime() != null) {
return order.getExpiredRefundUsageEndTime();
}
return order.getApptUseDate() == null ? null : order.getApptUseDate().atTime(23, 59, 59);
}
/**
* 同步单个历史订单的过期任务。已成功、已创建售后或已产生资金尝试的任务不允许被新配置覆盖,避免重扫造成重复退款。
*
* @param order 历史已支付未核销订单
* @param config 当前商户过期退款配置
* @param triggerTime 按当前延迟重新计算的触发时间
* @param restoreInventory 当前退库存快照
* @param enabled 当前过期退款开关
* @return 新建、刷新或跳过结果编码
*/
@Transactional(rollbackFor = Exception.class)
public int syncExpiredRescanTask(HardOrderEntity order, MchAutoRefundConfigVo config,
LocalDateTime triggerTime, String restoreInventory, boolean enabled) {
if (order == null || order.getId() == null || config == null || config.getId() == null || triggerTime == null) {
return RESCAN_SYNC_SKIPPED;
}
HardOrderEntity currentOrder = hardOrderMapper.selectByIdForUpdate(order.getId());
if (currentOrder == null || !Objects.equals(currentOrder.getMerchantId(), config.getMchId())
|| !Objects.equals(currentOrder.getOrderType(), HardOrderTypeEnum.RESERVE.getType())
|| !Objects.equals(currentOrder.getOrderStatus(), HardOrderStatusEnum.PAY.getType())
|| !Objects.equals(currentOrder.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType())
|| currentOrder.getPayPrice() == null || currentOrder.getPayPrice() <= 0
|| currentOrder.getRefundPrice() != null && currentOrder.getRefundPrice() > 0) {
return RESCAN_SYNC_SKIPPED;
}
Long conflictingAfterSaleCount = afterSaleMapper.selectCount(new LambdaQueryWrapper<HardAfterSaleEntity>()
.eq(HardAfterSaleEntity::getOrderId, currentOrder.getId())
.in(HardAfterSaleEntity::getStatus,
List.of(HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus())));
if (conflictingAfterSaleCount != null && conflictingAfterSaleCount > 0) {
return RESCAN_SYNC_SKIPPED;
}
HardAutoRefundTaskEntity existing = baseMapper.selectOne(new LambdaQueryWrapper<HardAutoRefundTaskEntity>()
.eq(HardAutoRefundTaskEntity::getOrderId, currentOrder.getId())
.eq(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()));
if (existing != null) {
boolean refreshableStatus = List.of(HardAutoRefundTaskStatusEnum.PENDING.getStatus(),
HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus(),
HardAutoRefundTaskStatusEnum.CONFIG_UNAVAILABLE.getStatus()).contains(existing.getStatus());
if (!refreshableStatus || !Objects.equals(existing.getMchId(), currentOrder.getMerchantId())
|| existing.getAfterSaleId() != null || safeCount(existing.getAutoAttemptCount()) > 0) {
return RESCAN_SYNC_SKIPPED;
}
existing.setConfigId(config.getId());
existing.setTriggerTime(triggerTime);
existing.setRestoreInventory(restoreInventory);
existing.setStatus(enabled ? HardAutoRefundTaskStatusEnum.PENDING.getStatus()
: HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus());
existing.setNextRetryTime(enabled ? triggerTime : null);
existing.setLastError(null);
if (baseMapper.updateById(existing) != 1) {
throw new RuntimeException("刷新过期预约退款任务失败");
}
return RESCAN_SYNC_REFRESHED;
}
HardAutoRefundTaskEntity task = HardAutoRefundTaskEntity.builder()
.taskType(HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
.orderId(currentOrder.getId()).mchId(currentOrder.getMerchantId())
.configId(config.getId()).triggerTime(triggerTime)
.restoreInventory(restoreInventory)
.status(enabled ? HardAutoRefundTaskStatusEnum.PENDING.getStatus()
: HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus())
.autoAttemptCount(0).totalAttemptCount(0)
.nextRetryTime(enabled ? triggerTime : null)
.build();
if (baseMapper.insert(task) != 1) {
throw new RuntimeException("创建过期预约退款任务失败");
}
return RESCAN_SYNC_CREATED;
}
/**
* 批量补充过期候选订单日期和餐段,避免把配置变更后的规则重新解析为退款时间。
*/
private void fillOrderData(List<HardAutoRefundTaskVo> records) {
List<Long> orderIds = records.stream().map(HardAutoRefundTaskVo::getOrderId).filter(Objects::nonNull).distinct().toList();
if (orderIds.isEmpty()) return;
@@ -1092,7 +1300,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
.and(expired -> expired.ne(HardAutoRefundTaskEntity::getTaskType,
HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
.or(expiredType -> expiredType.eq(HardAutoRefundTaskEntity::getTaskType,
HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
.le(HardAutoRefundTaskEntity::getTriggerTime, now)))))
.orderByAsc(HardAutoRefundTaskEntity::getNextRetryTime)
.orderByAsc(HardAutoRefundTaskEntity::getId)
@@ -1187,7 +1395,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
* 使用业务提示和原异常构造配置查询异常。
*
* @param message 不包含敏感信息的失败原因
* @param cause 原始远程异常
* @param cause 原始远程异常
*/
private CurrentConfigLookupException(String message, Throwable cause) {
super(message, cause);
@@ -398,8 +398,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
* @param queryDates 查询范围内按升序排列的全部日期
* @return 符合用户和预约状态条件的全部行
*/
private List<HardCanteenUserApptVo> listAllFilteredRows(HardCanteenUserApptQueryDto query,
List<LocalDate> queryDates) {
private List<HardCanteenUserApptVo> listAllFilteredRows(HardCanteenUserApptQueryDto query, List<LocalDate> queryDates) {
List<UserVO> users = fetchAllUsers(query);
return filterRows(buildRows(users, query, queryDates), query);
}
@@ -536,8 +535,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
* @return 含准确总数的用户分页
*/
private Page<UserVO> fetchUserPage(long current, long size, HardCanteenUserApptQueryDto query) {
R<Page<UserVO>> response = remoteUserService.getScopeBasicUserPage(current, size,
buildUserQuery(query));
R<Page<UserVO>> response = remoteUserService.getScopeBasicUserPage(current, size, buildUserQuery(query));
if (response == null || !response.isOk() || response.getData() == null) {
String message = response == null ? null : response.getMsg();
throw new IllegalStateException(StrUtil.isBlank(message) ? "查询权限用户列表失败" : message);
@@ -30,6 +30,7 @@ import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.hard.api.constant.order.HardCanteenStatisticsLevelEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardSaleSourceEnum;
@@ -50,6 +51,7 @@ import com.bsd.pjcloud.hard.excel.HardCanteenProductApptExportMergeHandler;
import com.bsd.pjcloud.hard.mapper.order.HardCarMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardCanteenTotalService;
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
@@ -97,81 +99,128 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
static private final String TAG = "食堂统计";
/** 当前用户没有可见食堂时使用的不可命中主键,防止空集合被解释为不限制商户。 */
/**
* 当前用户没有可见食堂时使用的不可命中主键,防止空集合被解释为不限制商户。
*/
private static final Long NO_VISIBLE_CANTEEN_ID = Long.MIN_VALUE;
/** 菜品统计异步导出的 Redis 业务命名空间。 */
/**
* 菜品统计异步导出的 Redis 业务命名空间。
*/
private static final String PRODUCT_SALES_EXPORT_REDIS_KEY = "hard:canteen-sales:export:product";
/** 部门统计异步导出的 Redis 业务命名空间。 */
/**
* 部门统计异步导出的 Redis 业务命名空间。
*/
private static final String DEPARTMENT_SALES_EXPORT_REDIS_KEY = "hard:canteen-sales:export:department";
/** 菜品统计异步导出任务展示名称。 */
/**
* 菜品统计异步导出任务展示名称。
*/
private static final String PRODUCT_SALES_EXPORT_TASK_NAME = "食堂菜品销售统计导出";
/** 部门统计异步导出任务展示名称。 */
/**
* 部门统计异步导出任务展示名称。
*/
private static final String DEPARTMENT_SALES_EXPORT_TASK_NAME = "食堂菜品部门统计导出";
/** 异步导出提交时使用的占位总数,后台开始后会替换为实际日期批次数。 */
/**
* 异步导出提交时使用的占位总数,后台开始后会替换为实际日期批次数。
*/
private static final int SALES_EXPORT_INITIAL_TOTAL = 1;
/** 流式写完日期批次后的工作簿收尾和文件上传进度步数。 */
/**
* 流式写完日期批次后的工作簿收尾和文件上传进度步数。
*/
private static final int SALES_EXPORT_EXTRA_PROGRESS_STEPS = 2;
/** 三级统计 Excel 的双层表头行数。 */
/**
* 三级统计 Excel 的双层表头行数。
*/
private static final int SALES_EXPORT_HEADER_ROW_COUNT = 2;
/** 单个三级统计工作表允许写入的最大业务行数。 */
/**
* 单个三级统计工作表允许写入的最大业务行数。
*/
private static final int MAX_SALES_EXPORT_ROWS =
BatchExcelExportTemplate.calculateMaxDetailRowCount(SALES_EXPORT_HEADER_ROW_COUNT, 0);
/** 统一销售统计异步导出的工作表名称。 */
/**
* 统一销售统计异步导出的工作表名称。
*/
private static final String SALES_EXPORT_SHEET_NAME = "食堂销售统计";
/** 统一销售统计异步导出的文件中心目录。 */
/**
* 统一销售统计异步导出的文件中心目录。
*/
private static final String SALES_EXPORT_FILE_DIR = "hard-export";
/** 菜品统计导出文件的文件中心业务类型。 */
/**
* 菜品统计导出文件的文件中心业务类型。
*/
private static final String PRODUCT_SALES_EXPORT_FILE_TYPE = "HARD_CANTEEN_PRODUCT_SALES_EXPORT";
/** 部门统计导出文件的文件中心业务类型。 */
/**
* 部门统计导出文件的文件中心业务类型。
*/
private static final String DEPARTMENT_SALES_EXPORT_FILE_TYPE = "HARD_CANTEEN_DEPARTMENT_SALES_EXPORT";
/** 导出聚合记录中的日期餐段汇总层级编码,必须与 Mapper SQL 返回值一致。 */
/**
* 导出聚合记录中的日期餐段汇总层级编码,必须与 Mapper SQL 返回值一致。
*/
private static final String SALES_EXPORT_RECORD_LEVEL_SUMMARY = "SUMMARY";
/** 导出聚合记录中的商户部门层级编码,必须与 Mapper SQL 返回值一致。 */
/**
* 导出聚合记录中的商户部门层级编码,必须与 Mapper SQL 返回值一致。
*/
private static final String SALES_EXPORT_RECORD_LEVEL_MERCHANT = "MERCHANT";
/** 导出聚合记录中的商品层级编码,必须与 Mapper SQL 返回值一致。 */
/**
* 导出聚合记录中的商品层级编码,必须与 Mapper SQL 返回值一致。
*/
private static final String SALES_EXPORT_RECORD_LEVEL_PRODUCT = "PRODUCT";
/** XLSX 文件的标准 MIME 类型。 */
/**
* XLSX 文件的标准 MIME 类型。
*/
private static final String SALES_EXPORT_EXCEL_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
/** 异步导出文件名使用的时间格式,保证同一用户连续导出时文件名可区分。 */
/**
* 异步导出文件名使用的时间格式,保证同一用户连续导出时文件名可区分。
*/
private static final DateTimeFormatter SALES_EXPORT_FILE_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
private final HardOrderMapper hardOrderMapper;
private final HardCarMapper hardCarMapper;
/** 用户身份查询支持器,负责通过 UPMS 将身份资料条件收敛为订单用户主键。 */
private final HardUserIdentitySupport userIdentitySupport;
private final RemoteDeptService remoteDeptService;
private final RemoteMchService remoteMchService;
private final RemoteDataScopeService dataScopeService;
private final RemoteCanteenCategoryService canteenCategoryService;
/** 每日菜谱远程服务,用于在三级导出中按业务日期展开套餐组成商品。 */
/**
* 每日菜谱远程服务,用于在三级导出中按业务日期展开套餐组成商品。
*/
private final RemoteDailyMenuService remoteDailyMenuService;
/** 餐段字典远程服务,仅在生成统计导出文案时读取 daily_menu。 */
/**
* 餐段字典远程服务,仅在生成统计导出文案时读取 daily_menu。
*/
private final RemoteDictService remoteDictService;
/** 文件中心远程服务,用于保存异步导出完成后的 XLSX 文件。 */
/**
* 文件中心远程服务,用于保存异步导出完成后的 XLSX 文件。
*/
private final RemoteFileService remoteFileService;
/** 通用异步任务执行器,用于恢复租户和用户上下文并持久化导出进度。 */
/**
* 通用异步任务执行器,用于恢复租户和用户上下文并持久化导出进度。
*/
private final AsyncTaskExecutorService asyncTaskExecutorService;
/**
@@ -285,8 +334,8 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
* <p>新版来源统计先读取日期、餐段和来源的完整多维结果,再按自然日分页装配为“当日合计 -> 餐段合计”,
* 每条合计行携带对应来源明细,避免来源行占据主表分组后无法查看整体数据。旧调用方未传统计日期时仍使用原 SQL。</p>
*
* @param page 分页参数;新版统计的每页数量表示自然日数量
* @param dto 统计日期、来源、食堂、餐段和部门筛选
* @param page 分页参数;新版统计的每页数量表示自然日数量
* @param dto 统计日期、来源、食堂、餐段和部门筛选
* @param dataScope 订单部门数据范围;为空时不追加数据权限条件
* @return 按自然日分页且包含餐段和来源明细的统计结果
*/
@@ -321,16 +370,16 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
* <p>交易人数等合计值直接使用对应数据库分组结果,禁止通过来源行相加生成;来源行只作为横向对比明细挂到合计行。
* 选择具体餐段时隐藏内容相同的当日合计,避免页面出现两条重复统计。</p>
*
* @param requestedPage 调用方请求的页码和每页自然日数量
* @param rows 数据库返回的当日合计、当日来源、餐段合计和餐段来源行
* @param hourTypeFiltered 是否已经按单一餐段筛选
* @param requestedPage 调用方请求的页码和每页自然日数量
* @param rows 数据库返回的当日合计、当日来源、餐段合计和餐段来源行
* @param hourTypeFiltered 是否已经按单一餐段筛选
* @param includeSourceRows 是否挂载来源拆分行;仅“全部”来源视图需要展示,单来源视图禁用以避免重复行
* @return 仅包含当前页自然日的合计行,每行附带来源对比数据
*/
private Page<HardCanteenProductTotalVo> assembleProductStatisticsPage(Page requestedPage,
List<HardCanteenProductTotalVo> rows,
boolean hourTypeFiltered,
boolean includeSourceRows) {
List<HardCanteenProductTotalVo> rows,
boolean hourTypeFiltered,
boolean includeSourceRows) {
Page<HardCanteenProductTotalVo> result = new Page<>(requestedPage.getCurrent(), requestedPage.getSize());
if (ObjectUtil.isEmpty(rows)) {
result.setTotal(0);
@@ -433,8 +482,8 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
* “部门 + 餐段”的跨来源合计为主行并挂载来源拆分。所有合计均来自数据库独立聚合,避免通过
* 来源行相加导致交易人数重复。未传统计日期范围时继续使用旧查询结构。</p>
*
* @param dto 统计日期、来源、食堂、餐段和部门筛选;totalIsFindChildren 决定是否查询展开明细
* @param user 当前登录用户,保留给既有商户范围契约
* @param dto 统计日期、来源、食堂、餐段和部门筛选;totalIsFindChildren 决定是否查询展开明细
* @param user 当前登录用户,保留给既有商户范围契约
* @param dataScope 订单所属部门数据范围;为空时不附加范围条件
* @return 食堂部门统计主行;新版主行附带来源拆分和部门餐段明细
*/
@@ -442,12 +491,10 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
public List<HardCanteenProductTotalVo> listByDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope) {
dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes());
// 查询所有的自营食堂
MchManageDto mchListDto = new MchManageDto();
mchListDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
// mchListDto.setScopeUserDeptId(user.getDeptId());
List<MchManageVo> mchList = remoteMchService.scopeList(mchListDto).getData();
if (ObjectUtil.isEmpty(mchList)) return null;
dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList());
List<Long> mchIdList = remoteMchService.getSelfMchIdList(user.getId(), List.of(MerchantTypeEnum.CANTEEN)).getData();
if (ObjectUtil.isEmpty(mchIdList)) return null;
dto.setMchIds(mchIdList);
applyUserIdentityQuery(dto);
boolean sourceStatistics = isSourceStatistics(dto);
if (sourceStatistics) normalizeSaleSources(dto);
List<HardCanteenProductTotalVo> mapperRows = sourceStatistics
@@ -494,15 +541,35 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
return list;
}
/**
* 将部门统计携带的身份、人脸及扩展资料条件转换为订单用户主键范围。
*
* <p>订单表不持久化学生、老师和人脸资料,必须先由 UPMS 在当前用户数据权限内筛选用户;
* 无匹配结果时支持器会返回不可命中的用户主键,避免 Mapper 将空集合误解为不限制用户。</p>
*
* @param dto 部门统计查询条件;方法会原地写入 userIds 并清除已经消费的身份资料条件
*/
private void applyUserIdentityQuery(HardOrderDto dto) {
if (!userIdentitySupport.hasIdentityQuery(dto)) {
return;
}
dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds()));
dto.setUserId(null);
dto.setIdentityTypes(null);
dto.setStudentInfo(null);
dto.setTeacherInfo(null);
dto.setHasFace(null);
}
/**
* 将部门统计 Mapper 主行装配为“日期 + 食堂合计 -> 来源拆分”的展示结构。
*
* @param rows Mapper 返回的日期食堂合计行和日期食堂来源行
* @param rows Mapper 返回的日期食堂合计行和日期食堂来源行
* @param includeSourceRows 是否挂载来源拆分;仅多来源视图需要挂载
* @return 日期食堂合计行列表,顺序沿用 Mapper 的日期和食堂排序
*/
private List<HardCanteenProductTotalVo> assembleDepartmentMainRows(List<HardCanteenProductTotalVo> rows,
boolean includeSourceRows) {
boolean includeSourceRows) {
if (ObjectUtil.isEmpty(rows)) return List.of();
List<HardCanteenProductTotalVo> sourceRows = rows.stream()
.filter(row -> ObjectUtil.isNotEmpty(row.getSaleSource()))
@@ -525,14 +592,14 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
/**
* 将部门展开 Mapper 行装配为“部门 + 餐段合计 -> 来源拆分”的展示结构。
*
* @param rows Mapper 返回的部门餐段合计行和部门餐段来源行
* @param merchantId 当前展开食堂主键,用于明细按钮继续继承食堂范围
* @param rows Mapper 返回的部门餐段合计行和部门餐段来源行
* @param merchantId 当前展开食堂主键,用于明细按钮继续继承食堂范围
* @param includeSourceRows 是否挂载来源拆分;仅多来源视图需要挂载
* @return 部门餐段合计行列表
*/
private List<HardCanteenProductTotalVo> assembleDepartmentChildRows(List<HardCanteenProductTotalVo> rows,
Long merchantId,
boolean includeSourceRows) {
Long merchantId,
boolean includeSourceRows) {
if (ObjectUtil.isEmpty(rows)) return List.of();
rows.forEach(row -> row.setMchId(merchantId));
List<HardCanteenProductTotalVo> sourceRows = rows.stream()
@@ -558,18 +625,18 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
* <p>旧实现按每个“日期 + 食堂”主行执行一次 SQL,日期或食堂增多时会形成 N+1;新版 SQL 已返回
* 食堂主键,因此可在相同筛选范围内一次聚合后按稳定键拆分,同时保持来源行和合计行的独立去重口径。</p>
*
* @param dto 已补全可见食堂、来源和统计日期的查询条件
* @param mainRows 日期食堂合计主行,每行可能挂载来源拆分
* @param dto 已补全可见食堂、来源和统计日期的查询条件
* @param mainRows 日期食堂合计主行,每行可能挂载来源拆分
* @param includeSourceRows 是否为合计行挂载来源拆分;单来源标签页为 false
*/
private void populateDepartmentStatisticsChildren(HardOrderDto dto,
List<HardCanteenProductTotalVo> mainRows,
boolean includeSourceRows) {
List<HardCanteenProductTotalVo> mainRows,
boolean includeSourceRows) {
List<HardCanteenProductTotalVo> allChildRows = hardCarMapper.listByHourTypeStats(dto);
Map<String, List<HardCanteenProductTotalVo>> childRowsByMainScope = ObjectUtil.isEmpty(allChildRows)
? Map.of()
: allChildRows.stream().collect(Collectors.groupingBy(this::departmentMainScopeKey,
LinkedHashMap::new, Collectors.toList()));
LinkedHashMap::new, Collectors.toList()));
for (HardCanteenProductTotalVo mainRow : mainRows) {
List<HardCanteenProductTotalVo> deptList = assembleDepartmentChildRows(
childRowsByMainScope.getOrDefault(departmentMainScopeKey(mainRow), List.of()),
@@ -586,12 +653,12 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
/**
* 从餐段合计行中提取指定来源的部门餐段行,供主表来源行展开时复用同一批查询结果。
*
* @param totalRows 部门餐段合计行,每行可能挂载来源拆分
* @param totalRows 部门餐段合计行,每行可能挂载来源拆分
* @param saleSource 主表来源行对应的稳定来源编码
* @return 指定来源的部门餐段行;没有匹配数据时返回空列表
*/
private List<HardCanteenProductTotalVo> departmentSourceChildren(List<HardCanteenProductTotalVo> totalRows,
String saleSource) {
String saleSource) {
if (ObjectUtil.isEmpty(totalRows) || ObjectUtil.isEmpty(saleSource)) return List.of();
return totalRows.stream()
.flatMap(row -> row.getSourceRows().stream())
@@ -654,7 +721,7 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
/**
* 将父级业务维度复制到缺少对应值的子级统计行,并继续处理其下级数据。
*
* @param parent 提供食堂、部门、日期和餐段上下文的父级统计行
* @param parent 提供食堂、部门、日期和餐段上下文的父级统计行
* @param children 待补充上下文的合计行或来源拆分行
*/
private void inheritDepartmentRowContext(HardCanteenProductTotalVo parent,
@@ -678,7 +745,7 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
* <p>每个日期餐段先输出当前来源筛选的跨来源合计块,再输出各已选来源块;合计人数由数据库独立去重,
* 不能通过来源人数相加得到。两个统计页面调用同一实现,因此筛选、退款扣减、套餐展开和合计规则保持一致。</p>
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @param dataScope 数据权限上下文,继续透传给 Mapper 拦截器
* @return 按日期餐段、来源口径、商户部门和商品顺序排列的 Excel 行
*/
@@ -722,15 +789,15 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
*
* <p>权限范围在请求线程完成收敛,后台线程只读取独立 DTO,避免调用方继续修改数组或列表导致导出条件漂移。</p>
*
* @param dto 页面提交的销售统计筛选条件
* @param redisKey 当前导出入口独立使用的 Redis 业务命名空间
* @param taskName 进度弹窗展示的任务名称
* @param dto 页面提交的销售统计筛选条件
* @param redisKey 当前导出入口独立使用的 Redis 业务命名空间
* @param taskName 进度弹窗展示的任务名称
* @param fileNamePrefix 下载文件名称前缀
* @param fileType 文件中心保存的业务类型编码
* @param fileType 文件中心保存的业务类型编码
* @return 已创建的初始任务进度
*/
private AsyncTaskProgressVO submitSalesExport(HardOrderDto dto, String redisKey, String taskName,
String fileNamePrefix, String fileType) {
String fileNamePrefix, String fileType) {
if (!isSourceStatistics(dto)) {
throw new IllegalArgumentException("导出必须选择统计日期范围");
}
@@ -760,10 +827,10 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
* <p>每个自然日完成 SQL 聚合后立即写盘并释放当日行,日期跨度增大时 JVM 不会同时持有全部导出结果。
* 进度总数由日期批次数、工作簿收尾和文件上传三部分组成。</p>
*
* @param query 已冻结权限范围的销售统计查询
* @param query 已冻结权限范围的销售统计查询
* @param fileNamePrefix 下载文件名称前缀
* @param fileType 文件中心业务类型编码
* @param reporter 当前异步任务进度报告器
* @param fileType 文件中心业务类型编码
* @param reporter 当前异步任务进度报告器
* @return 文件中心下载地址和业务文件名
*/
private AsyncTaskResult exportSalesToFile(HardOrderDto query, String fileNamePrefix, String fileType,
@@ -795,9 +862,9 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
* 按自然日批次查询三级统计行并写入同一个流式工作簿。
*
* @param outputPath 上传前使用的临时 XLSX 文件
* @param query 已冻结的完整筛选条件
* @param dates 从开始日期到结束日期的有序自然日列表
* @param reporter 当前任务进度报告器
* @param query 已冻结的完整筛选条件
* @param dates 从开始日期到结束日期的有序自然日列表
* @param reporter 当前任务进度报告器
* @return 实际写入的业务行总数
*/
private long writeSalesExportWorkbook(Path outputPath, HardOrderDto query, List<LocalDate> dates,
@@ -833,7 +900,7 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
* 创建单个自然日的查询副本,并保留用户在首尾日期选择的具体时间边界。
*
* @param query 已冻结的完整日期范围查询
* @param date 当前待处理的自然日
* @param date 当前待处理的自然日
* @return 日期范围已收敛到当前自然日的独立查询
*/
private HardOrderDto createExportDateQuery(HardOrderDto query, LocalDate date) {
@@ -853,8 +920,8 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
* 将已关闭的完整 XLSX 临时文件上传到统一文件中心。
*
* @param outputPath 已完成流式写入的临时文件
* @param fileName 用户下载时使用的 UTF-8 文件名
* @param fileType 文件中心业务类型编码
* @param fileName 用户下载时使用的 UTF-8 文件名
* @param fileType 文件中心业务类型编码
* @return 文件中心返回的下载地址
* @throws Exception 文件读取或远程上传失败时抛出
*/
@@ -932,7 +999,7 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
/**
* 查询一个自然日的三层销售数据并转换为 Excel 行。
*
* @param query 已收敛到单个自然日的查询条件
* @param query 已收敛到单个自然日的查询条件
* @param fixedDate 当前批次业务日期
* @param dataScope 数据权限上下文
* @return 当前日期下合计和各来源的日期餐段、商户部门、商品明细及商品统计行
@@ -1037,7 +1104,7 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
/**
* 从一次 SQL 返回的三级聚合记录中提取指定层级。
*
* @param records 当前自然日的全部导出聚合记录;允许为空
* @param records 当前自然日的全部导出聚合记录;允许为空
* @param recordLevel Mapper 返回的 SUMMARY、MERCHANT 或 PRODUCT 层级编码
* @return 保持数据库稳定排序的指定层级记录
*/
@@ -1056,7 +1123,7 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
* 之和严格等于订单快照金额。找不到套餐菜谱时保留原商品行。</p>
*
* @param product 商品聚合记录
* @param menu 与商户、日期、餐段和商品匹配的每日菜谱
* @param menu 与商户、日期、餐段和商品匹配的每日菜谱
* @return 原商品行或套餐组成商品行
*/
private List<HardCanteenProductApptExcelVo> toExportProductRows(
@@ -1118,9 +1185,9 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
/**
* 创建一个商品明细导出行。
*
* @param productName 商品展示名称
* @param productName 商品展示名称
* @param productCount 有效商品数量
* @param totalPrice 商品总价,单位为分
* @param totalPrice 商品总价,单位为分
* @return 只填写商品明细列的导出行
*/
private HardCanteenProductApptExcelVo toExportProductRow(String productName, long productCount,
@@ -1165,7 +1232,7 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
/**
* 查询当前日期涉及的每日菜谱并建立套餐展开缓存。
*
* @param products 当前日期的商品聚合记录
* @param products 当前日期的商品聚合记录
* @param statisticsDate 当前业务发生日期
* @return 商户、日期、餐段和商品主键到每日菜谱的映射;远程查询失败的项不写入
*/
@@ -1297,7 +1364,7 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
/**
* 解析导出记录的业务日期。
*
* @param row 日期餐段聚合记录
* @param row 日期餐段聚合记录
* @param fixedDate 当前按日批次的已知日期
* @return 可用业务日期;两者均为空或格式错误时返回空
*/
@@ -1336,7 +1403,7 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
/**
* 构造每日菜谱套餐缓存键。
*
* @param record 商品聚合记录
* @param record 商品聚合记录
* @param statisticsDate 业务发生日期
* @return 商户、日期、餐段和商品主键组成的稳定键
*/
@@ -1362,7 +1429,7 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
/**
* 解析餐段名称,字典缺失时保留协议编码。
*
* @param hourType 餐段协议编码
* @param hourType 餐段协议编码
* @param hourNames 餐段字典映射
* @return 餐段名称或原编码
*/
@@ -1909,16 +1909,21 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
}
/**
* 把下单时命中的配置身份、规则和可确定截止时间写入订单。周期规则缺少有效批次时整组快照留空。
* 把下单时命中的配置身份、规则和用餐日核销结束时间写入订单。核销结束时间不受退款开关控制,
* 即使当前关闭过期退款,也要保留订单自身快照,供以后调整延迟并立即重扫时使用。
*
* @param order 尚未入库的每日菜谱订单
* @param reservation 已校验预约上下文
* @param config 本批次唯一配置查询结果;为空时不写快照
* @param config 本批次唯一配置查询结果;为空时仍保留预约时间快照,但不创建退款配置快照
*/
private void applyDailyMenuAutoRefundSnapshot(HardOrderEntity order,
HardApptReservationService.ReservationValidation reservation,
MchAutoRefundConfigVo config) {
if (config == null || reservation == null) {
if (reservation == null) {
return;
}
order.setExpiredRefundUsageEndTime(reservation.usageEndDateTime());
if (config == null) {
return;
}
MchAutoRefundRuleTypeEnum rule = MchAutoRefundRuleTypeEnum.valueOfName(config.getRuleType());
@@ -1936,11 +1941,10 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
&& config.getExpiredReserveRefundDelayHours() != null
&& config.getExpiredReserveRefundDelayHours() >= 1
&& config.getExpiredReserveRefundDelayHours() <= 168
&& reservation.usageEndDateTime() != null
&& order.getExpiredRefundUsageEndTime() != null
&& config.getId() != null) {
order.setExpiredRefundConfigId(config.getId());
order.setExpiredRefundUsageEndTime(reservation.usageEndDateTime());
order.setExpiredRefundDeadline(reservation.usageEndDateTime().plusHours(config.getExpiredReserveRefundDelayHours()));
order.setExpiredRefundDeadline(order.getExpiredRefundUsageEndTime().plusHours(config.getExpiredReserveRefundDelayHours()));
order.setExpiredRefundRestoreInventory(Objects.equals(config.getExpiredReserveRefundRestoreInventory(), YesNoEnum.YES.getCode())
? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode());
}
@@ -2468,6 +2472,8 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
.walletId(dinerWallet.getId())
.isOfflineOrder(YesNoEnum.NO.getCode())
.apptUseDate(reservation.apptDate())
// 餐段配置修改频繁,必须在预约校验通过的下单时刻固化,后续重扫不得按当前配置反推历史订单。
.expiredRefundUsageEndTime(reservation.usageEndDateTime())
.productDesc(subject)
.build();
order.setHourType(reservation.hourType());
@@ -149,7 +149,8 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
HardOrderDto totalDto = BeanUtil.toBean(dto, HardOrderDto.class);
totalDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice());
DataScope totalDataScope = null;
if (ObjectUtil.isNotEmpty(dataScope)) {
if (dataScope != null) {
// DataScope 继承 HashMap,空对象仍包含数据权限配置,不能按 Map 非空判断丢弃权限范围。
totalDataScope = BeanUtils.toBean(dataScope, DataScope.class).func(DataScopeFuncEnum.ALL);
totalDataScope.setScopeDeptName("merchant_dept_id");
}
@@ -161,7 +162,8 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
refundDto.setRefundStatusCodes(List.of(HardSaleStatusEnum.COMPLETE.getStatus()));
refundDto.setOrderStatusCodes(HardOrderStatusEnum.getPayErrorCodes());
DataScope refundDataScope = null;
if (ObjectUtil.isNotEmpty(dataScope)) {
if (dataScope != null) {
// 退款汇总与支付汇总必须使用同一份租户及部门数据权限快照。
refundDataScope = BeanUtils.toBean(dataScope, DataScope.class).func(DataScopeFuncEnum.ALL);
refundDataScope.setScopeDeptName("merchant_dept_id");
}
@@ -68,4 +68,38 @@
</if>
order by t.trigger_time asc, t.id asc
</select>
<!--
立即重扫只读取仍可进入整单退款流程的每日菜谱钱包订单。
已存在处理中或已完成售后的订单由原售后和任务状态机继续处理,本接口不重置。
-->
<select id="listExpiredRescanOrders"
resultType="com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity">
select o.*
from hard_order o
inner join hard_parent_car p
on p.id = o.parent_car_id
and p.del_flag = '0'
where o.del_flag = '0'
and o.merchant_id = #{mchId}
and o.order_type = '2'
and o.sys_type = '2'
and o.order_status = '5'
and o.pay_type = '1'
and o.pay_price &gt; 0
and (o.refund_price is null or o.refund_price = 0)
and case
when json_valid(p.take_out_setting_json) = 1
then json_unquote(json_extract(p.take_out_setting_json, '$.cartScene'))
else null
end = 'DAILY_MENU'
and not exists (
select 1
from hard_after_sale s
where s.order_id = o.id
and s.del_flag = '0'
and s.status in ('10', '50')
)
order by o.id asc
</select>
</mapper>
@@ -161,6 +161,12 @@
#{v}
</foreach>
</if>
<if test="dto.userIds != null and dto.userIds.size() != 0">
and o.user_id in
<foreach collection="dto.userIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
</sql>
<!-- 商品名称只能在已连接购物车别名 a 的查询中下推,避免订单型退款子查询引用不存在的字段。 -->
@@ -928,7 +934,7 @@
</if>
</where>
group by a.product_id
order by SUM(a.product_count) desc
order by a.product_id desc, o.order_type asc, o.hour_type asc, SUM(a.product_count) desc
</select>
<!-- 新版菜品明细按来源查询,供主表“菜品详情”弹窗使用。 -->
@@ -1075,6 +1081,12 @@
#{v}
</foreach>
</if>
<if test="dto.userIds != null and dto.userIds.size() != 0">
and o.user_id in
<foreach collection="dto.userIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.payTimeList != null and dto.payTimeList.length == 2">
and o.pay_time between #{dto.payTimeList[0]} and #{dto.payTimeList[1]}
</if>
@@ -1182,6 +1194,12 @@
#{v}
</foreach>
</if>
<if test="dto.userIds != null and dto.userIds.size() != 0">
and o.user_id in
<foreach collection="dto.userIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.orderStatusCodes != null and dto.orderStatusCodes.size() != 0">
and o.order_status in
<foreach collection="dto.orderStatusCodes" item="v" open="(" close=")" separator=",">
@@ -167,6 +167,13 @@
#{v}
</foreach>
</if>
<!-- 订单列表勾选导出使用真实订单主键,避免历史 ids 参数未映射而退化为导出全部结果。 -->
<if test="dto.orderIds != null and dto.orderIds.size() != 0">
and a.id in
<foreach collection="dto.orderIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.mchIds != null and dto.mchIds.size() != 0">
and a.merchant_id in
<foreach collection="dto.mchIds" item="v" open="(" close=")" separator=",">
@@ -362,11 +369,15 @@
<when test="dto.verificationTimeList != null and dto.verificationTimeList.length == 2">
order by a.verification_time desc, a.id desc
</when>
<when test="dto.apptUseDateTimeList != null and dto.apptUseDateTimeList.length == 2">
order by a.appt_use_date desc, a.hour_type asc, a.id desc
</when>
<when test="dto.updateTimeList != null and dto.updateTimeList.length == 2">
order by a.update_time desc, a.id desc
</when>
<otherwise>
order by a.create_time desc, a.user_id desc
<!-- id 作为唯一兜底排序,保证异步导出跨页读取时不会因相同时间和用户出现重复或遗漏。 -->
order by a.create_time desc, a.user_id desc, a.id desc
</otherwise>
</choose>
</sql>
@@ -388,6 +399,9 @@
<when test="dto.verificationTimeList != null and dto.verificationTimeList.length == 2">
order by a.verification_time desc, a.id desc
</when>
<when test="dto.apptUseDateTimeList != null and dto.apptUseDateTimeList.length == 2">
order by a.appt_use_date desc, a.hour_type asc, a.id desc
</when>
<when test="dto.updateTimeList != null and dto.updateTimeList.length == 2">
order by a.update_time desc, a.id desc
</when>
@@ -58,7 +58,7 @@ class HardApptReservationServiceImplTest {
appt, APPT_DATE, LocalDateTime.of(2026, 7, 21, 10, 0, 0, 1)).available());
}
/** 动态预约直接快照已校验餐段的实际结束时间。 */
/** 动态预约固化用餐日当天 23:59:59 作为核销有效期结束时间。 */
@Test
void dynamicReservationUsesValidatedSegmentEndTime() {
HardApptEntity dynamic = timeWindowAppt();
@@ -71,16 +71,14 @@ class HardApptReservationServiceImplTest {
.validateReservation(10L, 1L, null, "1", APPT_DATE, null,
LocalDateTime.of(2026, 7, 20, 18, 0));
assertEquals(LocalDateTime.of(2026, 7, 21, 10, 0), validation.usageEndDateTime());
assertEquals(LocalDateTime.of(2026, 7, 21, 23, 59, 59), validation.usageEndDateTime());
}
/** 周期预约按有效 hard_appt 餐段解析结束时间,并支持结束时间落到用餐日次日。 */
/** 周期预约固化用餐日当天 23:59:59,不受当前 hard_appt 餐段配置影响。 */
@Test
void weeklyReservationResolvesCrossDaySegmentEndTime() {
void weeklyReservationUsesUseDateEndOfDay() {
HardApptEntity weekly = weeklyAppt(1L, 10L);
weekly.setHourType("1");
weekly.setEndTimeType(HardApptTimeTypeEnum.TOMORROW.getType());
weekly.setEndTime(LocalTime.of(2, 0));
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 7, "1");
@@ -88,20 +86,16 @@ class HardApptReservationServiceImplTest {
.validateReservation(10L, null, 100L, "1", LocalDate.of(2026, 7, 28), null,
LocalDateTime.of(2026, 7, 24, 18, 0));
assertEquals(LocalDateTime.of(2026, 7, 29, 2, 0), validation.usageEndDateTime());
assertEquals(LocalDateTime.of(2026, 7, 28, 23, 59, 59), validation.usageEndDateTime());
}
/** 周期餐段无法唯一匹配时不生成结束时间,避免按错误规则自动退款。 */
/** 周期预约即使存在重复餐段配置,也按订单用餐日固化全天核销有效期。 */
@Test
void weeklyReservationOmitsUsageEndTimeWhenSegmentIsAmbiguous() {
void weeklyReservationIgnoresDuplicateSegmentDefinitions() {
HardApptEntity weekly = weeklyAppt(1L, 10L);
weekly.setHourType("1");
weekly.setEndTimeType(HardApptTimeTypeEnum.TODAY.getType());
weekly.setEndTime(LocalTime.of(13, 0));
HardApptEntity duplicate = weeklyAppt(2L, 10L);
duplicate.setHourType("1");
duplicate.setEndTimeType(HardApptTimeTypeEnum.TODAY.getType());
duplicate.setEndTime(LocalTime.of(14, 0));
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 7, "1");
@@ -109,7 +103,7 @@ class HardApptReservationServiceImplTest {
.validateReservation(10L, null, 100L, "1", LocalDate.of(2026, 7, 28), null,
LocalDateTime.of(2026, 7, 24, 18, 0));
assertNull(validation.usageEndDateTime());
assertEquals(LocalDateTime.of(2026, 7, 28, 23, 59, 59), validation.usageEndDateTime());
}
@Test
@@ -4,11 +4,15 @@ import com.baomidou.lock.LockTemplate;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleMapper;
import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundAttemptLogMapper;
import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundTaskMapper;
@@ -28,6 +32,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDate;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -78,7 +83,6 @@ class HardAutoRefundTaskServiceImplTest {
/** 订单 Mapper Mock,用于候选订单和到期退款流程的订单锁查询。 */
@Mock
private HardOrderMapper orderMapper;
/** 被测自动退款任务服务。 */
private HardAutoRefundTaskServiceImpl service;
@@ -201,6 +205,67 @@ class HardAutoRefundTaskServiceImplTest {
assertThrows(RuntimeException.class, () -> spyService.manualExpiredRefund(1L, 99L));
}
/** 新订单专用核销结束时间必须优先于历史兜底,保证重扫始终使用下单时事实。 */
@Test
void expiredRescanShouldPreferDedicatedOrderSnapshot() {
HardOrderEntity order = HardOrderEntity.builder()
.expiredRefundUsageEndTime(LocalDateTime.of(2026, 8, 23, 13, 0))
.autoRefundDeadline(LocalDateTime.of(2026, 8, 23, 22, 0))
.apptUseDate(LocalDate.of(2026, 8, 23))
.build();
LocalDateTime result = ReflectionTestUtils.invokeMethod(service,
"resolveExpiredRefundUsageEndTimeSnapshot", order);
assertEquals(LocalDateTime.of(2026, 8, 23, 13, 0), result);
}
/** 历史订单没有专用字段时,只使用订单预约日期兜底到当天 23:59:59。 */
@Test
void expiredRescanShouldUseApptDateEndOfDayWhenDedicatedSnapshotMissing() {
HardOrderEntity order = HardOrderEntity.builder()
.autoRefundDeadline(LocalDateTime.of(2026, 8, 23, 22, 0))
.apptUseDate(LocalDate.of(2026, 8, 23))
.build();
LocalDateTime result = ReflectionTestUtils.invokeMethod(service,
"resolveExpiredRefundUsageEndTimeSnapshot", order);
assertEquals(LocalDateTime.of(2026, 8, 23, 23, 59, 59), result);
}
/** 开关关闭重扫只创建暂停任务,不会把历史订单推进到资金执行状态。 */
@Test
void disabledExpiredRescanShouldCreateSwitchDisabledTask() {
HardOrderEntity order = HardOrderEntity.builder()
.id(2091499615852482561L)
.merchantId(2079103103881199617L)
.orderType(HardOrderTypeEnum.RESERVE.getType())
.orderStatus(HardOrderStatusEnum.PAY.getType())
.payType(HardPayTypeEnum.WALLET_DEFAULT.getType())
.payPrice(4210)
.apptUseDate(LocalDate.of(2026, 8, 23))
.build();
MchAutoRefundConfigVo config = new MchAutoRefundConfigVo();
config.setId(2088157133475426306L);
config.setMchId(2079103103881199617L);
config.setOrderType(HardOrderTypeEnum.RESERVE.getType());
LocalDateTime triggerTime = LocalDateTime.of(2026, 8, 24, 0, 59, 59);
when(orderMapper.selectByIdForUpdate(order.getId())).thenReturn(order);
when(afterSaleMapper.selectCount(any())).thenReturn(0L);
when(taskMapper.selectOne(any())).thenReturn(null);
when(taskMapper.insert(any(HardAutoRefundTaskEntity.class))).thenReturn(1);
ArgumentCaptor<HardAutoRefundTaskEntity> taskCaptor = ArgumentCaptor.forClass(HardAutoRefundTaskEntity.class);
int result = service.syncExpiredRescanTask(order, config, triggerTime, YesNoEnum.NO.getCode(), false);
assertEquals(1, result);
verify(taskMapper).insert(taskCaptor.capture());
assertEquals(HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus(), taskCaptor.getValue().getStatus());
assertEquals(triggerTime, taskCaptor.getValue().getTriggerTime());
assertNull(taskCaptor.getValue().getNextRetryTime());
}
/**
* 构造固定订单和售后单的自动退款任务。
*
@@ -183,16 +183,36 @@ class HardOrderServiceImplVerificationTest {
config.setExpiredReserveRefundRestoreInventory("1");
HardApptReservationService.ReservationValidation validation = new HardApptReservationService.ReservationValidation(
null, "1", LocalDate.of(2026, 8, 23), 9L,
LocalDateTime.of(2026, 8, 22, 22, 0), LocalDateTime.of(2026, 8, 23, 13, 0));
LocalDateTime.of(2026, 8, 22, 22, 0), LocalDateTime.of(2026, 8, 23, 23, 59, 59));
ReflectionTestUtils.invokeMethod(service, "applyDailyMenuAutoRefundSnapshot", order, validation, config);
assertNull(order.getAutoRefundConfigId());
assertEquals(7L, order.getExpiredRefundConfigId());
assertEquals(LocalDateTime.of(2026, 8, 23, 15, 0), order.getExpiredRefundDeadline());
assertEquals(LocalDateTime.of(2026, 8, 24, 1, 59, 59), order.getExpiredRefundDeadline());
assertEquals("1", order.getExpiredRefundRestoreInventory());
}
/** 过期退款开关关闭时仍固化用餐日核销结束时间,后续重扫只能使用这份订单快照。 */
@Test
void disabledExpiredRefundShouldStillPersistUsageEndSnapshot() {
HardOrderEntity order = new HardOrderEntity();
MchAutoRefundConfigVo config = new MchAutoRefundConfigVo();
config.setId(7L);
config.setEnabled("0");
config.setExpiredReserveRefundEnabled("0");
config.setExpiredReserveRefundDelayHours(1);
HardApptReservationService.ReservationValidation validation = new HardApptReservationService.ReservationValidation(
null, "2", LocalDate.of(2026, 8, 23), 9L,
LocalDateTime.of(2026, 8, 23, 22, 0), LocalDateTime.of(2026, 8, 23, 23, 59, 59));
ReflectionTestUtils.invokeMethod(service, "applyDailyMenuAutoRefundSnapshot", order, validation, config);
assertEquals(LocalDateTime.of(2026, 8, 23, 23, 59, 59), order.getExpiredRefundUsageEndTime());
assertNull(order.getExpiredRefundConfigId());
assertNull(order.getExpiredRefundDeadline());
}
/**
* 构造旧消费机通用核销接口请求。
*
@@ -0,0 +1,59 @@
package com.bsd.pjcloud.thirdparty.dto.pay;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 商户分账关系管理参数。
*
* <p>租户和支付平台由 thirdparty 根据 {@code paySettingId} 重新读取;二级商户、
* 支付宝平台账号业务标识及授权上下文属于关系事实,由管理端在该子资源中维护;
* SDK 私钥和支付宝公钥仍由 thirdparty 平台统一配置持有。</p>
*/
@Data
public class ThirdMchFzRelationSaveDto {
/** 已有关系主键;新增时为空。 */
private Long id;
/** 关系所属商户主键,必须与支付配置所属商户一致。 */
@NotNull(message = "商户不能为空")
private Long mchId;
/** 关系使用的支付配置主键,决定渠道、二级商户和平台账号。 */
@NotNull(message = "支付配置不能为空")
private Long paySettingId;
/** 微信 sub_mchid 或支付宝直付通 smid/ip_role_id;编辑后变化时服务端创建新关系版本。 */
@Size(max = 64, message = "二级商户标识不能超过64个字符")
private String channelSubMchId;
/** 支付宝平台账号业务标识,仅用于关系审计;空字符串表示使用平台默认标识。 */
@Size(max = 64, message = "平台账号键不能超过64个字符")
private String accountKey;
/** 支付宝第三方代调用授权令牌;为空表示新增时不使用、编辑时保留原值。 */
@Size(max = 512, message = "支付宝授权令牌不能超过512个字符")
private String appAuthToken;
/** 渠道接收方类型;新增时必填,编辑后变化时必须同时提交完整新标识并创建新关系版本。 */
private String receiverType;
/** 渠道接收方账号、登录号、openId 或卡别名号;编辑时为空表示保留原完整值,非空变化时创建新关系版本。 */
@Size(max = 128, message = "接收方标识不能超过128个字符")
private String receiverAccount;
/** 渠道展示的接收方名称;支付宝和微信均允许为空,变化后原关系会使用新请求号重新绑定。 */
@Size(max = 128, message = "接收方名称不能超过128个字符")
private String receiverName;
/** 平台抽佣比例,单位为百分比,最多保留三位小数;新增关系时必填,修改默认关系时可省略。 */
private Double fzRatio;
/** 微信分账关系类型;支付宝关系不使用,变化后原关系会使用新请求号重新绑定。 */
@Size(max = 64, message = "关系类型不能超过64个字符")
private String channelRelationType;
/** 是否设为当前支付配置默认关系,1为是、0为否。 */
private String isDefault;
}
@@ -40,14 +40,18 @@ public class ThirdMchFzRelationEntity extends Model<ThirdMchFzRelationEntity> {
private String channelAppId;
/** 微信 sub_mchid 或支付宝 smid/ip_role_id,用于绑定关系与商户支付配置对应。 */
private String channelSubMchId;
/** 支付宝命名平台账号键;默认支付宝账号及微信关系为空。 */
/** 支付宝平台账号业务标识,仅用于关系审计;SDK 密钥统一读取服务端支付宝平台配置,微信关系为空。 */
private String accountKey;
/** 渠道接收方类型,微信为 MERCHANT_ID,支付宝为 userId 或 loginName。 */
/** 支付宝第三方代调用授权令牌;仅服务端渠道调用使用,管理接口不得返回完整值。 */
private String appAuthToken;
/** 渠道接收方类型,微信使用 SDK ReceiverType,支付宝支持 userId、loginName、openId、cardAliasNo。 */
private String receiverType;
/** 平台分佣接收账号,发起渠道请求时使用;管理端展示必须脱敏。 */
private String receiverAccount;
/** 平台分佣接收方名称快照,渠道不要求时允许为空。 */
private String receiverName;
/** 当前关系的平台抽佣比例,单位为百分比,范围为0到100,持久化精度为0.001%;新支付和分账金额只读取该关系快照。 */
private Double fzRatio;
/** 渠道关系类型,微信默认 SERVICE_PROVIDER,支付宝关系允许为空。 */
private String channelRelationType;
/** 渠道绑定稳定幂等号;支付宝重试必须保持不变,微信用于本地审计。 */
@@ -0,0 +1,51 @@
package com.bsd.pjcloud.thirdparty.vo.pay;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 管理端展示的商户分账关系。
*
* <p>接收方账号已脱敏,绑定请求和响应快照不返回浏览器,避免把渠道报文
* 或内部配置暴露给管理端页面。</p>
*/
@Data
public class ThirdMchFzRelationVo {
/** 关系主键。 */
private Long id;
/** 关系所属商户主键。 */
private Long mchId;
/** 关系使用的支付配置主键。 */
private Long paySettingId;
/** 支付平台编码:10微信、20支付宝。 */
private String platformType;
/** 微信子商户号或支付宝直付通 smid/ip_role_id。 */
private String channelSubMchId;
/** 支付宝平台账号业务标识,仅用于关系审计;微信为空。 */
private String accountKey;
/** 是否已配置支付宝授权令牌;管理端只接收布尔状态,绝不返回完整令牌。 */
private Boolean appAuthTokenConfigured;
/** 接收方类型。 */
private String receiverType;
/** 脱敏后的接收方标识。 */
private String receiverAccount;
/** 接收方展示名称。 */
private String receiverName;
/** 当前关系的平台抽佣比例,单位为百分比,最多保留三位小数(0.001%)。 */
private Double fzRatio;
/** 微信关系类型;支付宝为空。 */
private String channelRelationType;
/** 当前关系绑定状态。 */
private String status;
/** 是否为当前支付配置默认关系。 */
private String isDefault;
/** 渠道确认绑定时间。 */
private LocalDateTime boundTime;
/** 最近一次渠道状态。 */
private String lastExternalStatus;
/** 最近一次错误摘要。 */
private String bindError;
/** 最近一次绑定或查询时间。 */
private LocalDateTime lastQueryTime;
}
@@ -4,9 +4,6 @@ import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 小程序平台配置
*/
@@ -18,7 +15,7 @@ public class AppPlusPayConfig {
private WechatConfig wechatConfig;
private AliPayConfig aliPayConfig;
private AliPayConfig AliPayConfig;
@Data
@@ -156,26 +153,6 @@ public class AppPlusPayConfig {
*/
private String subscribeUrl;
/**
* 可选的平台支付宝命名账号,键由商户支付配置中的 accountKey 引用;空配置仍使用当前账号本身。
*/
private Map<String, AliPayConfig> accounts = new LinkedHashMap<>();
/**
* 直付通平台抽佣接收支付宝账号,类型使用 userId 或 loginName。
*/
private String royaltyTransIn;
/**
* 直付通平台抽佣接收账号类型,支付宝支持 userId 或 loginName。
*/
private String royaltyTransInType;
/**
* 直付通平台抽佣接收方名称,用于支付宝分账请求描述。
*/
private String royaltyTransInName;
}
}
@@ -0,0 +1,103 @@
package com.bsd.pjcloud.thirdparty.controller.pay;
import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdMchFzRelationSaveDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdMchFzRelationVo;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 商户支付分账关系管理接口。
*
* <p>关系属于支付配置的一对多子资源,二级商户、支付宝平台账号和代调用授权也归关系所有;
* 渠道 AppID 由 thirdparty 的平台统一配置解析,关系中的账号键只作为业务标识和审计快照。</p>
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/third-mch-fz-relation")
@Tag(name = "商户支付分账关系", description = "商户微信/支付宝分账关系管理")
public class ThirdMchFzRelationController {
/** 分账关系业务服务。 */
private final ThirdMchFzRelationService relationService;
/** 商户服务,用于复核当前管理员可访问的商户范围。 */
private final RemoteMchService remoteMchService;
/** 查询支付配置下的全部关系。 */
@Operation(summary = "查询分账关系")
@GetMapping("/list")
@PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')")
public R<List<ThirdMchFzRelationVo>> list(@RequestParam Long mchId, @RequestParam Long paySettingId) {
requireMerchantAccess(mchId);
return R.ok(relationService.listForManage(mchId, paySettingId));
}
/** 新增关系或编辑关系展示名称、抽佣比例、授权 token 和默认标记。 */
@Operation(summary = "保存分账关系")
@SysLog("保存商户支付分账关系")
@PostMapping
@PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')")
public R<ThirdMchFzRelationVo> save(@Valid @RequestBody ThirdMchFzRelationSaveDto dto) {
requireMerchantAccess(dto.getMchId());
return R.ok(relationService.saveForManage(dto));
}
/** 删除关系;已绑定关系由服务层保留为历史事实。 */
@Operation(summary = "删除分账关系")
@SysLog("删除商户支付分账关系")
@DeleteMapping
@PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')")
public R<Boolean> remove(@RequestBody List<Long> ids) {
if (ids != null) {
for (Long id : ids) {
ThirdMchFzRelationEntity relation = relationService.getById(id);
if (relation == null) {
continue;
}
requireMerchantAccess(relation.getMchId());
}
}
return R.ok(relationService.removeForManage(ids));
}
/** 使用原关系主键和原支付宝请求号重新排队绑定。 */
@Operation(summary = "重试分账关系绑定")
@SysLog("重试商户支付分账关系绑定")
@PostMapping("/{id}/retry")
@PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')")
public R<Boolean> retry(@PathVariable Long id, @RequestParam Long mchId) {
requireMerchantAccess(mchId);
ThirdMchFzRelationEntity relation = relationService.getById(id);
if (relation == null || !mchId.equals(relation.getMchId())) {
throw new IllegalArgumentException("分账关系不属于指定商户");
}
return R.ok(relationService.retryForManage(id));
}
/** 复核管理员商户数据权限,避免仅凭前端 mchId 越权查看其他商户关系。 */
private void requireMerchantAccess(Long mchId) {
R<List<Long>> result = remoteMchService.getSelfMchIdList(SecurityUtils.getUser().getId(), MerchantTypeEnum.all());
if (result == null || !result.isOk() || result.getData() == null || !result.getData().contains(mchId)) {
throw new IllegalArgumentException("当前用户无权管理该商户分账关系");
}
}
}
@@ -1,29 +1,87 @@
package com.bsd.pjcloud.thirdparty.service;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdMchFzRelationSaveDto;
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdMchFzRelationVo;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import java.util.List;
/**
* 商户支付分账关系服务,负责把 {@code mch_manage} 的支付配置映射为渠道关系事实。
*/
public interface ThirdMchFzRelationService {
/**
* 根据商户当前启用支付配置物化默认平台分佣关系;已有相同身份关系只做幂等复用。
* 查询商户支付配置下的全部关系,账号必须以脱敏视图返回。
*
* @param mchId 商户主键
* @param paySettingId 支付配置主键
* @return 当前租户未删除关系,默认关系排在前面
*/
List<ThirdMchFzRelationVo> listForManage(Long mchId, Long paySettingId);
/**
* 新增或更新关系管理记录;渠道身份变化时创建新版本,避免覆盖历史支付订单引用的关系事实。
*
* @param dto 管理端关系参数
* @return 保存后的脱敏关系;身份变化时返回新关系主键
*/
ThirdMchFzRelationVo saveForManage(ThirdMchFzRelationSaveDto dto);
/**
* 删除关系;已绑定关系只标记为历史关系,默认关系删除前必须自动切换到可用替代关系。
*
* @param ids 关系主键列表
* @return 全部处理成功时返回 true
*/
boolean removeForManage(List<Long> ids);
/**
* 将关系放回待绑定状态并创建稳定任务,人工重试不得生成新的关系或请求号。
*
* @param id 关系主键
* @return 任务已重新排队时返回 true
*/
boolean retryForManage(Long id);
/**
* 同步商户支付配置生命周期;渠道字段迁入关系表后,只停用已删除或禁用配置的默认关系。
*
* @param merchant 商户及其启用支付配置,来源于 wallet 服务的内部快照
*/
void syncDefaultRelations(MchManageVo merchant);
/**
* 获取支付创建时必须使用的已绑定关系;有抽佣配置但关系未绑定时直接拒绝支付。
* 获取支付创建时必须使用的默认且已绑定关系;关系缺失或未绑定时直接拒绝支付。
*
* @param setting 商户支付配置
* @return 已绑定关系;未启用平台抽佣时返回 {@code null}
* @return 当前默认且已绑定的关系事实
*/
ThirdMchFzRelationEntity requireBound(MchPaySettingVo setting);
/**
* 校验内部支付请求携带的关系仍属于当前商户支付配置,防止绕过编排层伪造关系主键。
*
* @param relationId 支付编排层解析出的关系主键
* @param setting 当前商户支付配置
* @return 默认、已绑定且抽佣比例有效的关系快照
*/
ThirdMchFzRelationEntity requirePaymentRelation(Long relationId, MchPaySettingVo setting);
/**
* 使用固定关系生成订单级渠道配置快照,供渠道调用及后续查单、退款恢复使用。
*
* <p>该快照只写入 thirdparty 支付订单,不回写 {@code mch_pay_setting.setting_json};
* 其中二级商户号、支付宝账号业务标识和授权令牌全部来源于关系记录;支付宝 SDK 密钥仍读取平台统一配置。</p>
*
* @param setting 当前启用的支付配置,只提供商户、平台和配置主键
* @param relation 已校验的默认关系事实
* @return 包含关系渠道参数的独立支付配置快照
*/
MchPaySettingVo createPaymentSettingSnapshot(MchPaySettingVo setting, ThirdMchFzRelationEntity relation);
/**
* 按支付订单保存的关系主键读取历史关系快照,供分账和退分账使用。
*
@@ -22,6 +22,7 @@ import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateAlipayOauthIdentity;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext;
@@ -33,6 +34,7 @@ import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
@@ -89,6 +91,10 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
*/
private final ThirdAppPlusAliPayService thirdAppPlusAliPayService;
/** 聚合子订单创建前校验支付宝直付通关系归属、绑定状态和抽佣比例。 */
@Autowired(required = false)
private ThirdMchFzRelationService mchFzRelationService;
/**
* 当前应用配置对应的支付宝客户端,延迟初始化并在请求间复用。
*/
@@ -281,7 +287,7 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
payOrder.setMchId(context.getMchId());
payOrder.setPrice(context.getAmount());
payOrder.setFzRelationId(context.getFzRelationId());
payOrder.setEnableFz(paySetting.getFzRatio() != null && paySetting.getFzRatio() > 0
payOrder.setEnableFz(context.getFzRelationId() != null
? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode());
payOrder.setPayNo(context.getPayNo());
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType());
@@ -326,18 +332,13 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
/** 聚合支付底层再次校验分佣关系,防止绕过编排层直接创建无关系直付通订单。 */
private void validateFzRelation(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting) {
if (paySetting.getFzRatio() == null || paySetting.getFzRatio() <= 0) {
if (paySetting.getFzRatio() != null && (!Double.isFinite(paySetting.getFzRatio()) || paySetting.getFzRatio() < 0)) {
throw new RuntimeException("支付宝平台抽佣比例不能为负数");
}
if (context == null || context.getFzRelationId() == null) {
return;
}
if (!Double.isFinite(paySetting.getFzRatio()) || paySetting.getFzRatio() > 100) {
throw new RuntimeException("支付宝平台抽佣比例不能超过100%");
}
if (context == null || context.getFzRelationId() == null) {
throw new RuntimeException("支付宝直付通平台分佣关系尚未绑定");
if (mchFzRelationService == null) {
throw new RuntimeException("支付宝平台分佣关系服务不可用");
}
mchFzRelationService.requirePaymentRelation(context.getFzRelationId(), paySetting);
}
/**
@@ -31,7 +31,6 @@ import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext;
import com.bsd.pjcloud.thirdparty.service.*;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
@@ -574,11 +573,11 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
ThirdAggregateCashierLaunch launch;
if (ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.ALI_PAY)) {
MchPaySettingVo setting = getRequiredAliPaySetting(merchant);
context.setFzRelationId(resolveFzRelation(setting));
setting = applyDefaultPaymentRelation(setting, context);
launch = aggregateAliPayService.createPay(context, setting, alipayIdentity);
} else {
MchPaySettingVo setting = getRequiredWechatPaySetting(merchant);
context.setFzRelationId(resolveFzRelation(setting));
setting = applyDefaultPaymentRelation(setting, context);
launch = aggregateWechatPayService.createPay(context, setting, payerIdentity);
}
if (!aggregatePaySessionService.markPaying(session.getId(), payNo)) {
@@ -1051,7 +1050,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
}
/**
* 选择已启用且包含直付通二级商户标识的支付宝商户配置;app_auth_token 可为空。
* 选择已启用的支付宝支付配置;直付通渠道参数在选定平台后从默认关系解析。
*
* @param merchant 收款商户详情
* @return 当前商户支付宝配置
@@ -1061,18 +1060,14 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
.filter(item -> ObjectUtil.equals(item.getPlatformType(), AppPlusPlatformPayType.ALI_PAY.getType()))
.filter(item -> ObjectUtil.equals(item.getEnable(), YesNoEnum.YES.getCode()))
.findFirst().orElse(null);
if (setting == null || StrUtil.isBlank(setting.getSettingJson())) {
if (setting == null) {
throw new RuntimeException("商户支付宝支付配置未启用");
}
MchPaySettingEntity.AliPaySetting detail = JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
if (detail == null || StrUtil.isBlank(detail.getSubMchid())) {
throw new RuntimeException("商户支付宝直付通二级商户标识未配置");
}
return setting;
}
/**
* 选择已启用且包含 sub_mchid 的微信商户配置。
* 选择已启用的微信支付配置;子商户号在选定平台后从默认关系解析。
*
* @param merchant 收款商户详情
* @return 当前商户微信支付配置
@@ -1082,13 +1077,9 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
.filter(item -> ObjectUtil.equals(item.getPlatformType(), AppPlusPlatformPayType.WECHAT.getType()))
.filter(item -> ObjectUtil.equals(item.getEnable(), YesNoEnum.YES.getCode()))
.findFirst().orElse(null);
if (setting == null || StrUtil.isBlank(setting.getSettingJson())) {
if (setting == null) {
throw new RuntimeException("商户微信支付配置未启用");
}
MchPaySettingEntity.WechatPaySetting detail = JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class);
if (detail == null || StrUtil.isBlank(detail.getSubMchid())) {
throw new RuntimeException("商户微信子商户号未配置");
}
return setting;
}
@@ -1214,14 +1205,16 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
}
/**
* 聚合支付在选定平台后解析默认平台分佣关系;多平台会话不会合并不同商户关系。
* 聚合支付选定平台后解析默认关系,并生成只属于当前子订单的渠道配置快照。
*
* @param setting 已确认启用的支付配置
* @return 绑定关系主键;抽佣比例未启用时为空
* @param context 聚合子订单创建上下文
* @return 由关系事实生成的支付配置快照
*/
private Long resolveFzRelation(MchPaySettingVo setting) {
private MchPaySettingVo applyDefaultPaymentRelation(MchPaySettingVo setting, ThirdAggregatePayCreateContext context) {
ThirdMchFzRelationEntity relation = mchFzRelationService.requireBound(setting);
return relation == null ? null : relation.getId();
context.setFzRelationId(relation.getId());
return mchFzRelationService.createPaymentSettingSnapshot(setting, relation);
}
/**
@@ -9,6 +9,7 @@ import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateWechatPayService;
@@ -28,6 +29,7 @@ import com.wechat.pay.java.service.partnerpayments.jsapi.model.Transaction;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
@@ -90,6 +92,10 @@ public class ThirdAggregateWechatPayServiceImpl implements ThirdAggregateWechatP
*/
private final ThirdPayOrderService thirdPayOrderService;
/** 聚合子订单创建前校验分账关系仍属于当前商户支付配置。 */
@Autowired(required = false)
private ThirdMchFzRelationService mchFzRelationService;
/**
* 调用服务商统一公众号 OAuth 接口换取 sp_openid。
*
@@ -163,9 +169,12 @@ public class ThirdAggregateWechatPayServiceImpl implements ThirdAggregateWechatP
request.setAmount(amount);
request.setPayer(payer);
SettleInfo settleInfo = new SettleInfo();
boolean enableFz = paySetting.getFzRatio() != null && paySetting.getFzRatio() > 0;
if (enableFz && context.getFzRelationId() == null) {
throw new RuntimeException("微信平台分佣关系尚未绑定");
boolean enableFz = context.getFzRelationId() != null;
if (enableFz) {
if (mchFzRelationService == null) {
throw new RuntimeException("微信平台分佣关系服务不可用");
}
mchFzRelationService.requirePaymentRelation(context.getFzRelationId(), paySetting);
}
settleInfo.setProfitSharing(enableFz);
request.setSettleInfo(settleInfo);
@@ -295,7 +304,7 @@ public class ThirdAggregateWechatPayServiceImpl implements ThirdAggregateWechatP
payOrder.setMchId(context.getMchId());
payOrder.setPrice(context.getAmount());
payOrder.setFzRelationId(context.getFzRelationId());
payOrder.setEnableFz(paySetting.getFzRatio() != null && paySetting.getFzRatio() > 0
payOrder.setEnableFz(context.getFzRelationId() != null
? com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode()
: com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.NO.getCode());
payOrder.setPayNo(context.getPayNo());
@@ -48,6 +48,7 @@ import com.alipay.api.response.AlipayTradeOrderSettleResponse;
import com.alipay.api.response.AlipayTradeOrderSettleQueryResponse;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationStatusEnum;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum;
@@ -78,6 +79,7 @@ import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;
@@ -155,15 +157,21 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
@Autowired(required = false)
private ThirdAggregatePaySessionService aggregatePaySessionService;
/** 实时写入支付查单和退款查单任务,不负责周期调度。 */
/**
* 实时写入支付查单和退款查单任务,不负责周期调度。
*/
@Autowired(required = false)
private ThirdPaymentTaskEnqueuer paymentTaskEnqueuer;
/** 支付订单创建时固定的平台分佣关系快照。 */
/**
* 支付订单创建时固定的平台分佣关系快照。
*/
@Autowired(required = false)
private ThirdMchFzRelationService mchFzRelationService;
/** 支付订单与首任务预留事务;可选注入仅用于兼容既有纯单元测试构造方式。 */
/**
* 支付订单与首任务预留事务;可选注入仅用于兼容既有纯单元测试构造方式。
*/
@Autowired(required = false)
private ThirdPaymentOrderReservationService paymentOrderReservationService;
@@ -204,58 +212,11 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
return alipayClient;
}
/**
* 按商户支付快照选择平台支付宝账号。accountKey 只作为平台配置键,
* 不接受客户端传入的应用密钥或账号内容。
*
* @param aliPaySetting 商户支付宝配置快照
* @return 对应支付宝客户端
*/
private AlipayClient getAlipayClient(MchPaySettingEntity.AliPaySetting aliPaySetting) {
AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(aliPaySetting);
if (config == appPlusPayConfig.getAliPayConfig()) {
return getAlipayClient();
}
try {
AlipayConfig alipayConfig = new AlipayConfig();
alipayConfig.setServerUrl("https://openapi.alipay.com/gateway.do");
alipayConfig.setAppId(config.getAppId());
alipayConfig.setPrivateKey(config.getAppPrivateKey());
alipayConfig.setFormat("json");
alipayConfig.setAlipayPublicKey(config.getAlipayPublicKey());
alipayConfig.setCharset("UTF-8");
alipayConfig.setSignType("RSA2");
return new DefaultAlipayClient(alipayConfig);
} catch (Exception exception) {
log.error("初始化命名支付宝客户端失败,accountKey:{}", aliPaySetting == null ? null : aliPaySetting.getAccountKey(), exception);
throw new RuntimeException("支付宝客户端初始化失败");
}
}
/**
* 解析商户配置引用的平台支付宝账号。
*
* @param aliPaySetting 商户支付宝配置
* @return 默认或命名支付宝账号配置
*/
private AppPlusPayConfig.AliPayConfig resolveAliPayConfig(MchPaySettingEntity.AliPaySetting aliPaySetting) {
AppPlusPayConfig.AliPayConfig defaultConfig = appPlusPayConfig.getAliPayConfig();
if (defaultConfig == null || StrUtil.isBlank(aliPaySetting == null ? null : aliPaySetting.getAccountKey())) {
return defaultConfig;
}
AppPlusPayConfig.AliPayConfig account = defaultConfig.getAccounts() == null
? null : defaultConfig.getAccounts().get(aliPaySetting.getAccountKey());
if (account == null) {
throw new RuntimeException("支付宝平台账号不存在");
}
return account;
}
/**
* 仅在配置了代调用授权令牌时加入公共参数,避免把空 token 发送给支付宝。
*
* @param textParamWriter 支付宝请求的附加文本参数写入方法
* @param aliPaySetting 商户支付宝配置
* @param aliPaySetting 商户支付宝配置
*/
private void putAppAuthToken(BiConsumer<String, String> textParamWriter,
MchPaySettingEntity.AliPaySetting aliPaySetting) {
@@ -279,11 +240,11 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
}
/**
* 按整数分计算平台抽佣金额,并拒绝无法形成至少一分钱有效分账的配置。
* 按整数分计算平台抽佣金额;向下取整不足一分钱时按一分钱提交。
*
* @param orderAmount 支付订单金额,单位分
* @param fzRatio 平台抽佣百分比
* @return 向下取整后的平台抽佣金额,单位分
* @param fzRatio 平台抽佣百分比
* @return 向下取整并应用一分钱下限后的平台抽佣金额,单位分
*/
private int calculateFzAmount(Integer orderAmount, Double fzRatio) {
if (orderAmount == null || orderAmount <= 0 || fzRatio == null || fzRatio <= 0 || fzRatio > 100) {
@@ -291,25 +252,23 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
}
int fzAmount = BigDecimal.valueOf(orderAmount).multiply(BigDecimal.valueOf(fzRatio))
.divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN).intValueExact();
if (fzAmount < 1 || fzAmount > orderAmount) {
// 支付宝直付通要求单笔平台抽佣至少1分,低比例向下取整为0时按1分提交。
fzAmount = Math.max(1, fzAmount);
if (fzAmount > orderAmount) {
throw new RuntimeException("支付宝直付通平台抽佣金额必须不少于1分且不能超过订单金额");
}
return fzAmount;
}
/**
* 解析支付回调地址。命名账号可以单独配置地址,未配置时复用平台默认回调地址。
* 解析平台统一支付宝支付回调地址。
*
* @param aliPaySetting 商户支付宝配置快照
* @return 完整支付回调地址
*/
private String resolvePayNotifyUrl(MchPaySettingEntity.AliPaySetting aliPaySetting) {
AppPlusPayConfig.AliPayConfig selected = resolveAliPayConfig(aliPaySetting);
AppPlusPayConfig.AliPayConfig defaults = appPlusPayConfig.getAliPayConfig();
String baseUrl = StrUtil.firstNonBlank(selected == null ? null : selected.getBaseCallbackUrl(),
defaults == null ? null : defaults.getBaseCallbackUrl());
String callbackUrl = StrUtil.firstNonBlank(selected == null ? null : selected.getPayCallbackUrl(),
defaults == null ? null : defaults.getPayCallbackUrl());
private String resolvePayNotifyUrl() {
AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.getAliPayConfig();
String baseUrl = config == null ? null : config.getBaseCallbackUrl();
String callbackUrl = config == null ? null : config.getPayCallbackUrl();
if (StrUtil.hasBlank(baseUrl, callbackUrl)) {
throw new RuntimeException("支付宝支付回调地址未配置");
}
@@ -317,18 +276,14 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
}
/**
* 解析退款回调地址。命名账号可以单独配置地址,未配置时复用平台默认回调地址。
* 解析平台统一支付宝退款回调地址。
*
* @param aliPaySetting 商户支付宝配置快照
* @return 完整退款回调地址
*/
private String resolveRefundNotifyUrl(MchPaySettingEntity.AliPaySetting aliPaySetting) {
AppPlusPayConfig.AliPayConfig selected = resolveAliPayConfig(aliPaySetting);
AppPlusPayConfig.AliPayConfig defaults = appPlusPayConfig.getAliPayConfig();
String baseUrl = StrUtil.firstNonBlank(selected == null ? null : selected.getBaseCallbackUrl(),
defaults == null ? null : defaults.getBaseCallbackUrl());
String callbackUrl = StrUtil.firstNonBlank(selected == null ? null : selected.getRefundCallbackUrl(),
defaults == null ? null : defaults.getRefundCallbackUrl());
private String resolveRefundNotifyUrl() {
AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.getAliPayConfig();
String baseUrl = config == null ? null : config.getBaseCallbackUrl();
String callbackUrl = config == null ? null : config.getRefundCallbackUrl();
if (StrUtil.hasBlank(baseUrl, callbackUrl)) {
throw new RuntimeException("支付宝退款回调地址未配置");
}
@@ -348,7 +303,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 按商户订单号查询支付宝交易状态。
*
* @param payNo 第三方支付服务生成的商户订单号
* @param payNo 第三方支付服务生成的商户订单号
* @param settingJson 创建订单时保存的商户配置快照
* @return 支付宝查询响应;解析、网络或 SDK 异常时返回 {@code null}
*/
@@ -361,7 +316,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
model.setOutTradeNo(payNo);
request.setBizModel(model);
putAppAuthToken(request::putOtherTextParam, aliPaySetting);
return getAlipayClient(aliPaySetting).execute(request);
return getAlipayClient().execute(request);
} catch (Exception e) {
log.error("|{}|查询支付宝订单失败,payNo:{},exceptionType:{}", TAG, payNo, e.getClass().getSimpleName());
}
@@ -371,8 +326,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 调用支付宝当面付预创建接口并保存订单及不含授权令牌的请求、响应快照。
*
* @param dto 已生成支付订单号的内部支付参数
* @param setting 当前商户启用的支付宝支付配置
* @param dto 已生成支付订单号的内部支付参数
* @param setting 当前商户启用的支付宝支付配置
* @param expireTime 服务端二维码失效时间
* @return 支付订单号、二维码内容串和服务端过期时间
*/
@@ -381,8 +336,13 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySetting(setting);
ThirdPayOrderEntity payOrder = buildOrderCodePayOrder(dto, setting, expireTime);
if (dto.getFzRelationId() != null) {
ThirdMchFzRelationEntity relation = mchFzRelationService == null
? null : mchFzRelationService.getById(dto.getFzRelationId());
requirePaymentCreateRelation(relation, payOrder, aliPaySetting, setting);
}
AlipayTradePrecreateRequest request = new AlipayTradePrecreateRequest();
request.setNotifyUrl(resolvePayNotifyUrl(aliPaySetting));
request.setNotifyUrl(resolvePayNotifyUrl());
AlipayTradePrecreateModel model = new AlipayTradePrecreateModel();
model.setOutTradeNo(dto.getOutTradeNo());
@@ -400,7 +360,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
AlipayTradePrecreateResponse response;
try {
response = getAlipayClient(aliPaySetting).execute(request);
response = getAlipayClient().execute(request);
} catch (Exception exception) {
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_FAILED.getType());
payOrder.setPayRes(buildExceptionSnapshot(exception).toString());
@@ -433,7 +393,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 调用支付宝撤销接口;异常时保留未支付状态,由上层定时任务继续重试。
*
* @param payNo 第三方支付服务生成的商户订单号
* @param payNo 第三方支付服务生成的商户订单号
* @param settingJson 创建订单时保存的商户支付配置快照
* @return 支付宝撤销响应,调用异常时返回 {@code null}
*/
@@ -446,7 +406,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
model.setOutTradeNo(payNo);
request.setBizModel(model);
putAppAuthToken(request::putOtherTextParam, aliPaySetting);
return getAlipayClient(aliPaySetting).execute(request);
return getAlipayClient().execute(request);
} catch (Exception exception) {
log.error("|{}|撤销支付宝订单失败,payNo:{},exceptionType:{}", TAG, payNo, exception.getClass().getSimpleName());
return null;
@@ -482,20 +442,27 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
if (dto.getAmount() == null || dto.getAmount() <= 10) {
throw new RuntimeException("支付宝直付通支付金额必须大于10分");
}
boolean enableFz = setting.getFzRatio() != null && setting.getFzRatio() > 0;
boolean enableFz = dto.getFzRelationId() != null;
if (enableFz) {
calculateFzAmount(dto.getAmount(), setting.getFzRatio());
// 上层通常已经通过 requireBound 解析关系;底层再次核对是为了防止内部调用者伪造
// fzRelationId,把其他商户、其他 smid 或尚未绑定的关系带入支付宝支付请求。
ThirdMchFzRelationEntity relation = mchFzRelationService == null
? null : mchFzRelationService.getById(dto.getFzRelationId());
requirePaymentCreateRelation(relation, payOrder, aliPaySetting, setting);
calculateFzAmount(dto.getAmount(), relation.getFzRatio());
}
payOrder.setEnableFz(enableFz
? com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode()
: com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.NO.getCode());
AlipayTradeAppPayRequest request = new AlipayTradeAppPayRequest();
request.setNotifyUrl(resolvePayNotifyUrl(aliPaySetting));
request.setNotifyUrl(resolvePayNotifyUrl());
AlipayTradeAppPayModel model = new AlipayTradeAppPayModel();
String totalAmount = fenToYuan(BigDecimal.valueOf(dto.getAmount()));
model.setOutTradeNo(dto.getOutTradeNo());
model.setTotalAmount(fenToYuan(BigDecimal.valueOf(dto.getAmount())));
model.setTotalAmount(totalAmount);
model.setSubject("资安信");
// 超时时间15分钟
model.setTimeExpire(LocalDateTimeUtil.format(now.plusMinutes(15), DatePattern.NORM_DATETIME_PATTERN));
@@ -504,18 +471,23 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
model.setSubMerchant(subMerchant);
SettleInfo directPaySettleInfo = new SettleInfo();
directPaySettleInfo.setSettlePeriodTime("1d");
// 支付通必须填写`SettleDetailInfo`
ArrayList<SettleDetailInfo> settleDetailInfoList = new ArrayList<>();
SettleDetailInfo defaultSettleDetailInfo = new SettleDetailInfo();
defaultSettleDetailInfo.setTransInType("defaultSettle");
defaultSettleDetailInfo.setAmount(totalAmount);
settleDetailInfoList.add(defaultSettleDetailInfo);
directPaySettleInfo.setSettleDetailInfos(settleDetailInfoList);
model.setSettleInfo(directPaySettleInfo);
payOrder.setPayRep(JSONUtil.toJsonStr(model));
request.setBizModel(model);
putAppAuthToken(request::putOtherTextParam, aliPaySetting);
if (paymentOrderReservationService != null) {
paymentOrderReservationService.reserveAppPay(payOrder);
}
try {
AlipayTradeAppPayResponse response = getAlipayClient(aliPaySetting).sdkExecute(request);
AlipayTradeAppPayResponse response = getAlipayClient().sdkExecute(request);
String orderStr = response.getBody();
payOrder.setPayRes(orderStr);
return orderStr;
} catch (Exception e) {
@@ -562,7 +534,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
updateObj.setIsBizCallback(com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.NO.getCode());
AlipayTradeRefundRequest request = new AlipayTradeRefundRequest();
request.setNotifyUrl(resolveRefundNotifyUrl(aliPaySetting));
request.setNotifyUrl(resolveRefundNotifyUrl());
AlipayTradeRefundModel model = new AlipayTradeRefundModel();
model.setOutTradeNo(thirdPayOrder.getPayNo());
@@ -626,7 +598,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
}
}
try {
AlipayTradeRefundResponse response = getAlipayClient(aliPaySetting).execute(request);
AlipayTradeRefundResponse response = getAlipayClient().execute(request);
updateObj.setRefundRes(response == null ? null : response.getBody());
// 退款接口返回 10000 只表示请求已受理,最终结果必须通过退款查询或回调确认。
if (response != null && !response.isSuccess() && !isRetryableAlipayError(response.getSubCode())) {
@@ -669,7 +641,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
AlipayTradeFastpayRefundQueryRequest request = new AlipayTradeFastpayRefundQueryRequest();
request.setBizModel(model);
putAppAuthToken(request::putOtherTextParam, aliPaySetting);
AlipayTradeFastpayRefundQueryResponse response = getAlipayClient(aliPaySetting).execute(request);
AlipayTradeFastpayRefundQueryResponse response = getAlipayClient().execute(request);
if (isAlipayRefundQueryMissing(response)) {
AlipayTradeRefundResponse retryResponse = retryAlipayRefund(payOrder, aliPaySetting);
// 重提成功只表示支付宝受理;网络异常或系统错误的结果仍未知,下一轮必须继续查询。
@@ -706,7 +678,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
* <p>支付宝以 out_request_no 保证幂等;只有明确业务错误才把查询响应改写为失败,
* 网络、系统和限频错误继续保留退款中供下一轮恢复。</p>
*
* @param payOrder 退款中的支付订单
* @param payOrder 退款中的支付订单
* @param aliPaySetting 支付创建时的支付宝商户配置
* @return 支付宝重提响应;无法安全重提或调用结果未知时为空
*/
@@ -722,9 +694,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
}
AlipayTradeRefundRequest retryRequest = new AlipayTradeRefundRequest();
retryRequest.setBizModel(retryModel);
retryRequest.setNotifyUrl(resolveRefundNotifyUrl(aliPaySetting));
retryRequest.setNotifyUrl(resolveRefundNotifyUrl());
putAppAuthToken(retryRequest::putOtherTextParam, aliPaySetting);
AlipayTradeRefundResponse retryResponse = getAlipayClient(aliPaySetting).execute(retryRequest);
AlipayTradeRefundResponse retryResponse = getAlipayClient().execute(retryRequest);
ThirdPayOrderEntity update = new ThirdPayOrderEntity();
update.setRefundRes(retryResponse == null ? null : retryResponse.getBody());
thirdPayOrderService.update(update, new LambdaUpdateWrapper<ThirdPayOrderEntity>()
@@ -742,9 +714,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 保存支付宝退款中退分账部分的最终响应和状态。
*
* @param fzOrder 原分账订单;未发生退分账时允许为空
* @param fzOrder 原分账订单;未发生退分账时允许为空
* @param response 支付宝退款响应
* @param status 分账退款终态
* @param status 分账退款终态
*/
private void updateAlipayFzRefundResult(ThirdFzOrderEntity fzOrder, AlipayTradeRefundResponse response,
ThirdFzOrderStatusEnum status) {
@@ -783,6 +755,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 预留支付宝直付通分账事实;外部请求必须由后续独立任务执行。
*
* @param payOrder 已确认支付成功的支付宝订单
* @return 已存在或新建的分账订单;订单尚未满足分账条件时为空
*/
@@ -809,7 +782,10 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
fzOrder.setTenantId(payOrder.getTenantId());
fzOrder.setPayNo(payOrder.getPayNo());
fzOrder.setFzNo("fz_" + payOrder.getPayNo());
fzOrder.setPrice(calculateFzAmount(payOrder.getPrice(), paySetting.getFzRatio()));
ThirdMchFzRelationEntity relation = mchFzRelationService == null ? null
: mchFzRelationService.getById(payOrder.getFzRelationId());
requireDirectPayRelation(relation, payOrder, requireAliPaySetting(paySetting));
fzOrder.setPrice(calculateFzAmount(payOrder.getPrice(), relation.getFzRatio()));
fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_UNPAID.getType());
fzOrder.setFzTime(LocalDateTime.now());
fzOrder.setCreateRes(JSONUtil.createObj().set("provider", "ALIPAY").toString());
@@ -826,7 +802,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
* 补偿支付宝直付通结算、平台抽佣及已分账退款。支付宝此链路没有可替代本地幂等号的轮询结果,
* 因此对超时或系统异常重复提交相同请求,显式业务失败才进入失败状态。
*
* @param fzOrder 已持久化的分账订单
* @param fzOrder 已持久化的分账订单
* @param payOrder 对应的支付订单
*/
@Override
@@ -856,7 +832,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 执行并保存确认结算单阶段;相同阶段终态重复调用不会再次请求支付宝。
* @param fzOrder 分账订单
*
* @param fzOrder 分账订单
* @param payOrder 支付订单
* @return SUCCESS、PROCESSING 或 FAILED
*/
@@ -891,7 +868,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 提交平台抽佣单阶段;提交成功只记录 SUBMITTED,最终成功由查询阶段裁定。
* @param fzOrder 分账订单
*
* @param fzOrder 分账订单
* @param payOrder 支付订单
* @return SUBMITTED、PROCESSING 或 FAILED
*/
@@ -933,7 +911,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 查询平台抽佣最终结果;提交响应成功本身不能进入最终分账成功。
* @param fzOrder 分账订单
*
* @param fzOrder 分账订单
* @param payOrder 支付订单
* @return SUCCESS、PROCESSING 或 FAILED
*/
@@ -992,7 +971,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
return status;
}
/** 读取并补齐支付宝分账快照根节点。 */
/**
* 读取并补齐支付宝分账快照根节点。
*/
private JSONObject readDirectPaySnapshot(ThirdFzOrderEntity fzOrder) {
JSONObject snapshot = StrUtil.isBlank(fzOrder.getCreateRes())
? JSONUtil.createObj() : JSONUtil.parseObj(fzOrder.getCreateRes());
@@ -1000,7 +981,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
return snapshot;
}
/** 读取支付订单创建时保存的商户设置快照。 */
/**
* 读取支付订单创建时保存的商户设置快照。
*/
private MchPaySettingEntity requireMchPaySetting(ThirdPayOrderEntity payOrder) {
MchPaySettingEntity setting = payOrder == null || StrUtil.isBlank(payOrder.getMchPaySettingJson())
? null : JSONUtil.toBean(payOrder.getMchPaySettingJson(), MchPaySettingEntity.class);
@@ -1010,12 +993,16 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
return setting;
}
/** 从支付订单快照读取并校验直付通二级商户设置。 */
/**
* 从支付订单快照读取并校验直付通二级商户设置。
*/
private MchPaySettingEntity.AliPaySetting requireAliPaySetting(ThirdPayOrderEntity payOrder) {
return requireAliPaySetting(requireMchPaySetting(payOrder));
}
/** 从商户设置中读取并校验支付宝直付通配置。 */
/**
* 从商户设置中读取并校验支付宝直付通配置。
*/
private MchPaySettingEntity.AliPaySetting requireAliPaySetting(MchPaySettingEntity setting) {
MchPaySettingEntity.AliPaySetting aliPaySetting = StrUtil.isBlank(setting.getSettingJson())
? null : JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
@@ -1025,7 +1012,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
return aliPaySetting;
}
/** 将分账订单终态转换为支付宝阶段状态。 */
/**
* 将分账订单终态转换为支付宝阶段状态。
*/
private String resolvePersistedFzStatus(ThirdFzOrderEntity fzOrder) {
if (fzOrder == null) {
return "FAILED";
@@ -1039,7 +1028,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
return "PROCESSING";
}
/** 创建不含账号、密钥和授权令牌的确定性失败阶段快照。 */
/**
* 创建不含账号、密钥和授权令牌的确定性失败阶段快照。
*/
private JSONObject failedDirectPayStage(String error) {
return JSONUtil.createObj().set("status", "FAILED")
.set("error", StrUtil.blankToDefault(error, "支付宝直付通配置无效"))
@@ -1049,9 +1040,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 原子保存直付通阶段快照及可选终态,禁止较晚返回的旧请求覆盖并发实例已写入的成功或退款状态。
*
* @param fzOrder 当前轮询读取的待处理分账订单
* @param snapshot 合并确认结算和平台抽佣后的结构化快照
* @param stage 本轮刚完成的阶段,用于提取请求和错误摘要
* @param fzOrder 当前轮询读取的待处理分账订单
* @param snapshot 合并确认结算和平台抽佣后的结构化快照
* @param stage 本轮刚完成的阶段,用于提取请求和错误摘要
* @param terminalStatus 本轮明确得到的最终状态;处理中时为空
* @return 仅当数据库记录仍处于待处理状态且本次更新成功时返回 {@code true}
*/
@@ -1100,7 +1091,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 查询支付宝退款并同步退分账终态;退款查询至少由调度任务在发起十秒后执行。
*
* @param fzOrder 退分账中的分账订单
* @param fzOrder 退分账中的分账订单
* @param payOrder 对应退款中的支付订单
*/
private void pollAlipayFzRefund(ThirdFzOrderEntity fzOrder, ThirdPayOrderEntity payOrder) {
@@ -1140,7 +1131,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 调用支付宝确认结算接口并保存稳定请求号。
*
* @param payOrder 支付订单
* @param payOrder 支付订单
* @param aliPaySetting 商户支付宝配置
* @return 确认结算阶段快照
*/
@@ -1169,7 +1160,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
stage.set("outRequestNo", outRequestNo);
stage.set("request", JSONUtil.parseObj(JSONUtil.toJsonStr(model)));
try {
AlipayTradeSettleConfirmResponse response = getAlipayClient(aliPaySetting).execute(request);
AlipayTradeSettleConfirmResponse response = getAlipayClient().execute(request);
stage.set("response", response == null ? null : JSONUtil.parseObj(JSONUtil.toJsonStr(response)));
stage.set("externalStatus", response == null ? "EMPTY_RESPONSE" : response.getSubCode());
stage.set("status", resolveSettleConfirmStatus(response));
@@ -1201,14 +1192,14 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 调用支付宝平台抽佣分账接口。
*
* @param payOrder 支付订单
* @param payOrder 支付订单
* @param mchPaySetting 商户支付设置
* @param aliPaySetting 商户支付宝配置
* @param fzOrder 分账订单
* @param fzOrder 分账订单
* @return 平台抽佣阶段快照
*/
private JSONObject settleDirectPayRoyalty(ThirdPayOrderEntity payOrder, MchPaySettingEntity mchPaySetting,
MchPaySettingEntity.AliPaySetting aliPaySetting, ThirdFzOrderEntity fzOrder) {
MchPaySettingEntity.AliPaySetting aliPaySetting, ThirdFzOrderEntity fzOrder) {
JSONObject stage = JSONUtil.createObj();
String outRequestNo = "royalty_" + payOrder.getPayNo();
ThirdMchFzRelationEntity relation = mchFzRelationService == null ? null : mchFzRelationService.getById(payOrder.getFzRelationId());
@@ -1251,7 +1242,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
stage.set("outRequestNo", outRequestNo);
stage.set("request", JSONUtil.parseObj(JSONUtil.toJsonStr(model)));
try {
AlipayTradeOrderSettleResponse response = getAlipayClient(aliPaySetting).execute(request);
AlipayTradeOrderSettleResponse response = getAlipayClient().execute(request);
stage.set("response", response == null ? null : JSONUtil.parseObj(JSONUtil.toJsonStr(response)));
stage.set("externalStatus", response == null ? "EMPTY_RESPONSE" : response.getSubCode());
stage.set("status", resolveRoyaltySubmissionStatus(response));
@@ -1267,27 +1258,25 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
}
/**
* 校验平台抽佣接收方账户类型,直付通分账只允许支付宝 userId 或 loginName。
*
* @param accountType 支付宝账户类型
* 校验平台抽佣接收方账户类型,编码直接传给支付宝分账和退分账模型。
*/
private void validateRoyaltyAccountType(String accountType) {
if (!"userId".equals(accountType) && !"loginName".equals(accountType)) {
throw new RuntimeException("平台支付宝抽佣接收方类型只允许userId或loginName");
if (!List.of("userId", "loginName", "openId", "cardAliasNo").contains(accountType)) {
throw new RuntimeException("平台支付宝抽佣接收方类型只允许userId、loginName、openId或cardAliasNo");
}
}
/**
* 校验支付宝支付订单固定的直付通关系;历史关系允许继续退分账,但禁止回退当前可变配置。
*
* @param relation 支付创建时保存的关系快照
* @param payOrder 关系所属支付订单
* @param relation 支付创建时保存的关系快照
* @param payOrder 关系所属支付订单
* @param aliPaySetting 支付订单创建时保存的支付宝配置快照
*/
private void requireDirectPayRelation(ThirdMchFzRelationEntity relation,
ThirdPayOrderEntity payOrder,
MchPaySettingEntity.AliPaySetting aliPaySetting) {
AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(aliPaySetting);
ThirdPayOrderEntity payOrder,
MchPaySettingEntity.AliPaySetting aliPaySetting) {
AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.getAliPayConfig();
if (relation == null || !"0".equals(relation.getDelFlag())
|| relation.getBoundTime() == null
|| payOrder == null
@@ -1296,12 +1285,38 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
|| config == null
|| !ObjectUtil.equals(relation.getChannelAppId(), config.getAppId())
|| !ObjectUtil.equals(relation.getChannelSubMchId(), aliPaySetting == null ? null : aliPaySetting.getSubMchid())
|| relation.getFzRatio() == null || !Double.isFinite(relation.getFzRatio())
|| relation.getFzRatio() <= 0 || relation.getFzRatio() > 100
|| StrUtil.hasBlank(relation.getReceiverAccount(), relation.getReceiverType())) {
throw new IllegalStateException("支付宝支付订单的固定直付通分佣关系不存在或与二级商户不一致");
}
validateRoyaltyAccountType(relation.getReceiverType());
}
/**
* 校验新支付只能使用当前支付配置唯一默认且已绑定的关系。
*
* <p>历史关系允许旧订单退款和退分账,但绝不能继续承接新支付;因此支付创建校验
* 比历史订单分账校验额外要求关系状态、默认标记和支付配置主键完全一致。</p>
*
* @param relation 内部关系主键查到的关系事实
* @param payOrder 即将创建的支付订单
* @param aliPaySetting 当前支付宝配置
* @param setting 当前商户支付配置
*/
private void requirePaymentCreateRelation(ThirdMchFzRelationEntity relation,
ThirdPayOrderEntity payOrder,
MchPaySettingEntity.AliPaySetting aliPaySetting,
MchPaySettingVo setting) {
requireDirectPayRelation(relation, payOrder, aliPaySetting);
if (!ObjectUtil.equals(relation.getId(), payOrder.getFzRelationId())
|| !ObjectUtil.equals(relation.getPaySettingId(), setting == null ? null : setting.getId())
|| !ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(relation.getStatus())
|| !com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode().equals(relation.getIsDefault())) {
throw new IllegalStateException("支付宝新支付只能使用当前支付配置默认且已绑定的分佣关系");
}
}
/**
* 将平台抽佣响应映射为本地阶段状态,结算状态传播和限频按可重试处理。
*
@@ -1322,14 +1337,15 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 使用交易号和稳定抽佣请求号查询支付宝分账结果。
* @param payOrder 支付订单
*
* @param payOrder 支付订单
* @param aliPaySetting 支付订单创建时保存的支付宝配置
* @param outRequestNo 平台抽佣稳定请求号
* @param outRequestNo 平台抽佣稳定请求号
* @return 抽佣查询阶段快照
*/
private JSONObject queryDirectPayRoyalty(ThirdPayOrderEntity payOrder,
MchPaySettingEntity.AliPaySetting aliPaySetting,
String outRequestNo) {
MchPaySettingEntity.AliPaySetting aliPaySetting,
String outRequestNo) {
JSONObject stage = JSONUtil.createObj();
AlipayTradeOrderSettleQueryModel model = new AlipayTradeOrderSettleQueryModel();
model.setTradeNo(payOrder.getThirdPayNo());
@@ -1339,7 +1355,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
putAppAuthToken(request::putOtherTextParam, aliPaySetting);
stage.set("request", JSONUtil.parseObj(JSONUtil.toJsonStr(model)));
try {
AlipayTradeOrderSettleQueryResponse response = getAlipayClient(aliPaySetting).execute(request);
AlipayTradeOrderSettleQueryResponse response = getAlipayClient().execute(request);
stage.set("response", response == null ? null : JSONUtil.parseObj(JSONUtil.toJsonStr(response)));
stage.set("externalStatus", resolveRoyaltyExternalStatus(response));
String status = resolveRoyaltyQueryStatus(response);
@@ -1358,6 +1374,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 将支付宝抽佣查询结果映射为本地阶段状态。
*
* @param response 支付宝抽佣查询响应
* @return SUCCESS、PROCESSING 或 FAILED
*/
@@ -1381,7 +1398,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
return anyFailed ? "FAILED" : "PROCESSING";
}
/** 返回抽佣查询中最适合监控聚合的外部状态。 */
/**
* 返回抽佣查询中最适合监控聚合的外部状态。
*/
private String resolveRoyaltyExternalStatus(AlipayTradeOrderSettleQueryResponse response) {
if (response == null) {
return "EMPTY_RESPONSE";
@@ -1396,7 +1415,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
.filter(StrUtil::isNotBlank).distinct().sorted().collect(java.util.stream.Collectors.joining(","));
}
/** 返回抽佣查询的确定性错误摘要,不包含支付宝账户或授权信息。 */
/**
* 返回抽佣查询的确定性错误摘要,不包含支付宝账户或授权信息。
*/
private String resolveRoyaltyQueryError(AlipayTradeOrderSettleQueryResponse response) {
if (response == null) {
return "EMPTY_RESPONSE";
@@ -1414,7 +1435,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 将支付宝平台抽佣成功状态原子写入支付订单,防止重复轮询累加分账金额。
*
* @param fzOrder 分账订单
* @param fzOrder 分账订单
* @param payOrder 支付订单
*/
private void markDirectPayFzSuccess(ThirdFzOrderEntity fzOrder, ThirdPayOrderEntity payOrder) {
@@ -1450,7 +1471,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
* 记录可重试的直付通阶段错误,不把网络异常误判为永久分账失败。
*
* @param fzOrder 分账订单
* @param error 错误摘要
* @param error 错误摘要
*/
private void recordFzError(ThirdFzOrderEntity fzOrder, String error) {
if (ObjectUtil.equals(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_SUCCESS.getType())) {
@@ -1595,16 +1616,16 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
}
/**
* 校验支付宝回调应用与支付订单创建时保存的命名账号快照一致。
* 校验支付宝回调应用与平台统一支付宝账号一致。
*
* @param callbackAppId 回调中的支付宝应用 ID
* @param payOrder 回调对应的支付订单
* @return 回调应用与默认或命名账号完全一致时返回 {@code true}
* @param payOrder 回调对应的支付订单
* @return 回调应用与平台统一账号完全一致时返回 {@code true}
*/
private boolean validateCallbackAccount(String callbackAppId, ThirdPayOrderEntity payOrder) {
try {
MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySettingSnapshot(payOrder.getMchPaySettingJson());
AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(aliPaySetting);
parseAliPaySettingSnapshot(payOrder.getMchPaySettingJson());
AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.getAliPayConfig();
return config != null && ObjectUtil.equals(config.getAppId(), callbackAppId);
} catch (Exception exception) {
return false;
@@ -1649,8 +1670,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 构建订单码支付的待支付订单,完整记录业务、平台、金额、配置和过期时间。
*
* @param dto 内部支付参数
* @param setting 商户支付宝配置
* @param dto 内部支付参数
* @param setting 商户支付宝配置
* @param expireTime 服务端二维码失效时间
* @return 可直接持久化的待支付订单
*/
@@ -1668,24 +1689,27 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
payOrder.setMchId(dto.getMchId());
payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting));
payOrder.setFzRelationId(dto.getFzRelationId());
payOrder.setEnableFz(setting.getFzRatio() != null && setting.getFzRatio() > 0
payOrder.setEnableFz(dto.getFzRelationId() != null
? com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode()
: com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.NO.getCode());
payOrder.setExpireTime(expireTime);
return payOrder;
}
/** 支付宝支付底层统一校验抽佣比例与已绑定关系,避免内部调用绕过上层编排。 */
/**
* 支付宝支付底层统一校验抽佣比例与已绑定关系,避免内部调用绕过上层编排。
*/
private void validateFzRatioAndRelation(ThirdAppPlusPayDto dto, MchPaySettingVo setting) {
if (setting == null || setting.getFzRatio() == null || setting.getFzRatio() == 0) {
if (dto == null || dto.getFzRelationId() == null) {
return;
}
if (!Double.isFinite(setting.getFzRatio()) || setting.getFzRatio() < 0 || setting.getFzRatio() > 100) {
throw new RuntimeException("支付宝直付通平台抽佣比例必须大于等于0且不超过100%");
}
if (dto == null || dto.getFzRelationId() == null) {
throw new RuntimeException("支付宝直付通平台分佣关系尚未绑定");
ThirdMchFzRelationEntity relation = mchFzRelationService == null ? null
: mchFzRelationService.getById(dto.getFzRelationId());
if (relation == null || relation.getFzRatio() == null || !Double.isFinite(relation.getFzRatio())
|| relation.getFzRatio() <= 0 || relation.getFzRatio() > 100) {
throw new RuntimeException("支付宝直付通平台抽佣关系或比例无效");
}
calculateFzAmount(dto.getAmount(), relation.getFzRatio());
}
/**
@@ -1775,31 +1799,23 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
}
/**
* 按回调 app_id 查找默认或命名支付宝账号,避免使用错误公钥验签。
* 按回调 app_id 核对平台统一支付宝账号,避免使用错误公钥验签。
*
* @param appId 支付宝回调中的应用 ID
* @return 与应用 ID 唯一匹配的平台支付宝账号;找不到时返回 {@code null}
* @return 应用 ID 与平台账号一致时返回配置,否则返回 {@code null}
*/
private AppPlusPayConfig.AliPayConfig resolveCallbackAliPayConfig(String appId) {
AppPlusPayConfig.AliPayConfig defaults = appPlusPayConfig.getAliPayConfig();
if (defaults == null || StrUtil.isBlank(appId)) {
return null;
}
if (ObjectUtil.equals(defaults.getAppId(), appId)) {
return defaults;
}
if (defaults.getAccounts() == null) {
return null;
}
return defaults.getAccounts().values().stream()
.filter(config -> config != null && ObjectUtil.equals(config.getAppId(), appId))
.findFirst().orElse(null);
return ObjectUtil.equals(defaults.getAppId(), appId) ? defaults : null;
}
/**
* 校验回调应用、商户订单、支付平台及金额均与内部订单一致。
*
* @param params 已通过签名校验的支付宝回调参数
* @param params 已通过签名校验的支付宝回调参数
* @param payOrder 回调商户订单号对应的内部支付订单
* @return 全部业务约束一致时返回 {@code true}
*/
@@ -1813,7 +1829,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
log.warn("|{}|支付宝支付回调配置快照无效,payNo:{}", TAG, payNo);
return false;
}
AppPlusPayConfig.AliPayConfig expectedConfig = resolveAliPayConfig(aliPaySetting);
AppPlusPayConfig.AliPayConfig expectedConfig = appPlusPayConfig.getAliPayConfig();
if (expectedConfig == null || ObjectUtil.notEqual(params.get("app_id"), expectedConfig.getAppId())) {
log.warn("|{}|支付宝支付回调appId不匹配,payNo:{}", TAG, payNo);
return false;
@@ -1850,7 +1866,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 校验聚合会话与实际第三方子订单的业务、商户和金额快照一致。
*
* @param session {@code pay_no} 绑定的聚合支付会话
* @param session {@code pay_no} 绑定的聚合支付会话
* @param payOrder 支付宝回调对应的第三方子订单
* @return 关键业务字段完全一致时返回 {@code true}
*/
@@ -50,7 +50,6 @@ import com.bsd.pjcloud.wallet.api.constant.WalleCardStatusEnum;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargePaySuccess;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeRefundSuccess;
import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargePackageEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
@@ -415,9 +414,7 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
// 微信小程序
if (ObjectUtil.equals(dto.getMiniPlatformType().getType(), AppPlusPlatformTypeEnum.WECHAT.getType())) {
MchPaySettingVo paySetting = getEnabledWechatPaySetting(mchManage);
appPlusPayDto.setFzRelationId(resolveFzRelation(paySetting));
MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class);
appPlusPayDto.setSubMchid(wechatPaySetting.getSubMchid());
paySetting = applyDefaultPaymentRelation(paySetting, appPlusPayDto);
Map<String, Object> appPlusPayParams;
try {
@@ -434,9 +431,7 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
// 支付宝
else if (ObjectUtil.equals(dto.getMiniPlatformType().getType(), AppPlusPlatformTypeEnum.ALI_PAY.getType())) {
MchPaySettingVo paySetting = getEnabledAliPaySetting(mchManage);
appPlusPayDto.setFzRelationId(resolveFzRelation(paySetting));
MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
appPlusPayDto.setSubMchid(aliPaySetting.getSubMchid());
paySetting = applyDefaultPaymentRelation(paySetting, appPlusPayDto);
String orderStr;
try {
@@ -593,12 +588,10 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
// 微信
if (ObjectUtil.equals(dto.getMiniPlatformType().getType(), AppPlusPlatformTypeEnum.WECHAT.getType())) {
MchPaySettingVo paySetting = paySettingList.stream().filter(item -> ObjectUtil.equals(item.getPlatformType(), AppPlusPlatformPayType.WECHAT.getType())).findFirst().orElse(null);
if (ObjectUtil.isEmpty(paySetting) || ObjectUtil.isEmpty(paySetting.getSettingJson())) {
if (ObjectUtil.isEmpty(paySetting)) {
throw new RuntimeException("商户未配置微信支付参数");
}
MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class);
appPlusPayDto.setFzRelationId(resolveFzRelation(paySetting));
appPlusPayDto.setSubMchid(wechatPaySetting.getSubMchid());
paySetting = applyDefaultPaymentRelation(paySetting, appPlusPayDto);
String wxOutTradeNo = thirdAppPlusWechatMicropayService.micropay(appPlusPayDto, paySetting);
if (ObjectUtil.isNotEmpty(wxOutTradeNo)) {
@@ -611,9 +604,7 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
if (ObjectUtil.isEmpty(paySetting)) {
throw new RuntimeException("商户未配置微信支付参数");
}
MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
appPlusPayDto.setFzRelationId(resolveFzRelation(paySetting));
appPlusPayDto.setSubMchid(aliPaySetting.getSubMchid());
applyDefaultPaymentRelation(paySetting, appPlusPayDto);
}
// 不支持的支付类型
else {
@@ -739,12 +730,12 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
LocalDateTime expireTime = LocalDateTime.now().plusMinutes(ORDER_CODE_EXPIRE_MINUTES);
if (ObjectUtil.equals(dto.getMiniPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY)) {
MchPaySettingVo paySetting = getEnabledAliPaySetting(merchant);
payDto.setFzRelationId(resolveFzRelation(paySetting));
paySetting = applyDefaultPaymentRelation(paySetting, payDto);
validateAliPayApplicationConfig();
return thirdMiniAppAliPayService.precreate(payDto, paySetting, expireTime);
}
MchPaySettingVo paySetting = getEnabledWechatPaySetting(merchant);
payDto.setFzRelationId(resolveFzRelation(paySetting));
paySetting = applyDefaultPaymentRelation(paySetting, payDto);
validateWechatApplicationConfig();
return thirdAppPlusWechatNativePayService.precreate(payDto, paySetting, expireTime);
}
@@ -753,22 +744,20 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
* 校验订单码支付首版允许的业务、平台、金额和商户参数。
*/
/**
* 解析支付配置对应的已绑定平台分佣关系,未启用抽佣时不写关系主键。
* 解析默认关系并生成订单级渠道配置快照,二级商户与支付宝授权均不得再从支付配置 JSON 读取。
*
* @param paySetting 已由商户服务确认启用的支付配置
* @return 已绑定关系主键;平台抽佣比例为零时为空
* @param payDto 即将提交渠道服务的内部支付参数
* @return 由关系事实生成的独立支付配置快照
*/
private Long resolveFzRelation(MchPaySettingVo paySetting) {
private MchPaySettingVo applyDefaultPaymentRelation(MchPaySettingVo paySetting, ThirdAppPlusPayDto payDto) {
if (mchFzRelationService == null) {
// 独立单元测试或历史单体启动未装载关系模块时,不改变无分佣配置的既有支付路径;
// 生产容器必须提供关系服务,启用抽佣时由其严格拒绝未绑定支付。
if (paySetting == null || paySetting.getFzRatio() == null || paySetting.getFzRatio() <= 0) {
return null;
}
throw new IllegalStateException("支付平台分佣关系服务不可用");
}
com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity relation = mchFzRelationService.requireBound(paySetting);
return relation == null ? null : relation.getId();
payDto.setFzRelationId(relation.getId());
payDto.setSubMchid(relation.getChannelSubMchId());
return mchFzRelationService.createPaymentSettingSnapshot(paySetting, relation);
}
private void validateOrderCodePayRequest(ThirdOrderCodePayDto dto) {
@@ -888,20 +877,9 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
.filter(item -> ObjectUtil.equals(item.getEnable(), YesNoEnum.YES.getCode()))
.findFirst()
.orElse(null);
if (paySetting == null || StrUtil.isBlank(paySetting.getSettingJson())) {
if (paySetting == null) {
throw new RuntimeException("商户未启用支付宝支付配置");
}
try {
MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
if (aliPaySetting == null || StrUtil.isBlank(aliPaySetting.getSubMchid())) {
throw new RuntimeException("商户支付宝直付通二级商户标识未配置");
}
} catch (RuntimeException exception) {
if ("商户支付宝直付通二级商户标识未配置".equals(exception.getMessage())) {
throw exception;
}
throw new RuntimeException("商户支付宝支付配置无效");
}
return paySetting;
}
@@ -914,21 +892,9 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
.filter(item -> ObjectUtil.equals(item.getEnable(), YesNoEnum.YES.getCode()))
.findFirst()
.orElse(null);
if (paySetting == null || StrUtil.isBlank(paySetting.getSettingJson())) {
if (paySetting == null) {
throw new RuntimeException("商户未启用微信支付配置");
}
try {
MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean(
paySetting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class);
if (wechatPaySetting == null || StrUtil.isBlank(wechatPaySetting.getSubMchid())) {
throw new RuntimeException("商户微信子商户号未配置");
}
} catch (RuntimeException exception) {
if ("商户微信子商户号未配置".equals(exception.getMessage())) {
throw exception;
}
throw new RuntimeException("商户微信支付配置无效");
}
return paySetting;
}
@@ -14,6 +14,7 @@ import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatMicropayService;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.utils.pay.PayForUtil;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
@@ -27,6 +28,7 @@ import com.wechat.pay.java.service.partnerpayments.app.model.Transaction;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
@@ -53,6 +55,10 @@ public class ThirdAppPlusWechatMicropayServiceImpl implements ThirdAppPlusWechat
/** 支付成功后写入数据库分账任务,不创建进程内延迟线程。 */
private final ThirdPaymentTaskEnqueuer paymentTaskEnqueuer;
/** 付款码创建时校验支付订单固定的关系归属、状态和比例。 */
@Autowired(required = false)
private ThirdMchFzRelationService mchFzRelationService;
/**
* 付款码支付
*/
@@ -86,11 +92,15 @@ public class ThirdAppPlusWechatMicropayServiceImpl implements ThirdAppPlusWechat
request.setSpbillCreateIp(PayForUtil.localIp());
request.setAuthCode(dto.getDevicePayCode());
request.setProfitSharing("N");
// 是否需要分账
if (dto.getAmount() > 10 && ObjectUtil.isNotEmpty(setting) && ObjectUtil.isNotEmpty(setting.getFzRatio()) && setting.getFzRatio() > 0) {
if (dto.getFzRelationId() == null) {
throw new RuntimeException("微信平台分佣关系尚未绑定");
// 分账由关系主键唯一启用,金额过小不能静默降级为普通支付。
if (dto.getFzRelationId() != null) {
if (dto.getAmount() == null || dto.getAmount() <= 10) {
throw new RuntimeException("微信分账支付金额必须大于10分");
}
if (mchFzRelationService == null) {
throw new RuntimeException("微信平台分佣关系服务不可用");
}
mchFzRelationService.requirePaymentRelation(dto.getFzRelationId(), setting);
request.setProfitSharing("Y");
payOrder.setEnableFz(YesNoEnum.YES.getCode());
} else {
@@ -8,7 +8,9 @@ import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatNativePayService;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
@@ -25,6 +27,7 @@ import com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
@@ -72,6 +75,10 @@ public class ThirdAppPlusWechatNativePayServiceImpl implements ThirdAppPlusWecha
*/
private final ThirdPayOrderService thirdPayOrderService;
/** 支付创建时校验关系主键归属、绑定状态和抽佣比例。 */
@Autowired(required = false)
private ThirdMchFzRelationService mchFzRelationService;
/**
* 创建微信服务商 Native 订单,只有 SDK 返回非空 {@code code_url} 才视为成功。
*
@@ -100,9 +107,12 @@ public class ThirdAppPlusWechatNativePayServiceImpl implements ThirdAppPlusWecha
request.setNotifyUrl(wechatConfig.getBaseCallbackUrl() + wechatConfig.getPayCallbackUrl());
request.setAmount(amount);
SettleInfo settleInfo = new SettleInfo();
boolean enableFz = setting.getFzRatio() != null && setting.getFzRatio() > 0;
if (enableFz && dto.getFzRelationId() == null) {
throw new RuntimeException("微信平台分佣关系尚未绑定");
boolean enableFz = dto.getFzRelationId() != null;
if (enableFz) {
if (mchFzRelationService == null) {
throw new RuntimeException("微信平台分佣关系服务不可用");
}
mchFzRelationService.requirePaymentRelation(dto.getFzRelationId(), setting);
}
settleInfo.setProfitSharing(enableFz);
request.setSettleInfo(settleInfo);
@@ -209,7 +219,7 @@ public class ThirdAppPlusWechatNativePayServiceImpl implements ThirdAppPlusWecha
payOrder.setMchId(dto.getMchId());
payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting));
payOrder.setFzRelationId(dto.getFzRelationId());
payOrder.setEnableFz(setting.getFzRatio() != null && setting.getFzRatio() > 0
payOrder.setEnableFz(dto.getFzRelationId() != null
? com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode()
: com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.NO.getCode());
payOrder.setExpireTime(expireTime);
@@ -19,11 +19,13 @@ import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.*;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdPaymentOrderReservationService;
import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.service.impl.appplus.fz.lifecycle.ThirdAppPlusWechatFzLifecycle;
import com.bsd.pjcloud.thirdparty.utils.pay.wechat.ThirdAppPlusWechantPayConstant;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.wechat.*;
@@ -93,6 +95,9 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService
@Autowired(required = false)
private ThirdPaymentOrderReservationService paymentOrderReservationService;
/** 支付创建时校验关系归属和比例;属于支付安全前置条件,禁止以可选依赖方式绕过。 */
private final ThirdMchFzRelationService mchFzRelationService;
/**
* 创建微信服务商 App 支付,子商户号和分账比例只接受服务端商户配置快照。
*
@@ -130,10 +135,7 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService
SettleInfo settleInfo = new SettleInfo();
settleInfo.setProfitSharing(false);
// 是否需要分账
if (ObjectUtil.isNotEmpty(setting.getFzRatio()) && setting.getFzRatio() > 0) {
if (dto.getFzRelationId() == null) {
throw new RuntimeException("微信平台分佣关系尚未绑定");
}
if (dto.getFzRelationId() != null) {
settleInfo.setProfitSharing(true);
payOrder.setEnableFz(YesNoEnum.YES.getCode());
} else {
@@ -198,19 +200,19 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService
if (wechatPaySetting == null || StrUtil.isBlank(wechatPaySetting.getSubMchid())) {
throw new RuntimeException("微信支付子商户号未配置");
}
if (setting.getFzRatio() == null || setting.getFzRatio() <= 0) {
if (dto.getFzRelationId() == null) {
return wechatPaySetting;
}
if (setting.getFzRatio() > 100) {
throw new RuntimeException("微信平台抽佣比例必须大于0且不超过100%");
}
ThirdMchFzRelationEntity relation = mchFzRelationService.requirePaymentRelation(dto.getFzRelationId(), setting);
if (dto.getAmount() <= 10) {
throw new RuntimeException("微信分账支付金额必须大于10分");
}
long fzAmount = BigDecimal.valueOf(dto.getAmount())
.multiply(BigDecimal.valueOf(setting.getFzRatio()))
.multiply(BigDecimal.valueOf(relation.getFzRatio()))
.divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN).longValueExact();
if (fzAmount < 1L || fzAmount > dto.getAmount()) {
// 微信分账单笔金额至少1分,低比例向下取整为0时按1分提交。
fzAmount = Math.max(1L, fzAmount);
if (fzAmount > dto.getAmount()) {
throw new RuntimeException("微信平台抽佣金额必须不少于1分且不能超过订单金额");
}
return wechatPaySetting;
@@ -59,9 +59,8 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ
*/
private final ThirdPaymentTaskEnqueuer paymentTaskEnqueuer;
/** 支付订单创建时固定的平台分佣关系快照。 */
@org.springframework.beans.factory.annotation.Autowired(required = false)
private ThirdMchFzRelationService mchFzRelationService;
/** 支付订单创建时固定的平台分佣关系服务;分账和退分账必须依赖该历史关系事实。 */
private final ThirdMchFzRelationService mchFzRelationService;
/**
* 发起分账
@@ -91,11 +90,6 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ
}
MchPaySettingEntity mchPaySetting = JSONUtil.toBean(orderEntity.getMchPaySettingJson(), MchPaySettingEntity.class);
MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean(mchPaySetting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class);
if (mchPaySetting.getFzRatio() == null || mchPaySetting.getFzRatio() <= 0
|| mchPaySetting.getFzRatio() > 100) {
throw new RuntimeException("微信平台抽佣比例必须大于0且不超过100%");
}
String outOrderNo = "fz" + orderEntity.getPayNo();
ThirdFzOrderEntity fzOrderEntity = new ThirdFzOrderEntity();
@@ -107,17 +101,21 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ
fzOrderEntity.setStatus(ThirdFzOrderStatusEnum.FZ_UNPAID.getType());
AppPlusPayConfig.WechatConfig wechatConfig = appPlusPayConfig.getWechatConfig();
ThirdMchFzRelationEntity relation = mchFzRelationService == null ? null
: mchFzRelationService.getById(orderEntity.getFzRelationId());
ThirdMchFzRelationEntity relation = mchFzRelationService.getById(orderEntity.getFzRelationId());
requireWechatRelation(relation, orderEntity, wechatPaySetting.getSubMchid(), wechatConfig);
if (relation.getFzRatio() == null || !Double.isFinite(relation.getFzRatio()) || relation.getFzRatio() <= 0 || relation.getFzRatio() > 100) {
throw new RuntimeException("微信平台抽佣关系或比例无效");
}
String receiverAccount = relation.getReceiverAccount();
String receiverName = StrUtil.blankToDefault(relation.getReceiverName(), "浙江资安信信息科技有限公司");
// 最少分账1分
Long amount = BigDecimal.valueOf(orderEntity.getPrice())
.multiply(BigDecimal.valueOf(mchPaySetting.getFzRatio()))
.multiply(BigDecimal.valueOf(relation.getFzRatio()))
.divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN).longValueExact();
if (amount < 1L || amount > orderEntity.getPrice()) {
// 微信分账单笔金额至少1分,低比例向下取整为0时按1分提交。
amount = Math.max(1L, amount);
if (amount > orderEntity.getPrice()) {
throw new RuntimeException("微信平台抽佣金额必须不少于1分且不能超过订单金额");
}
@@ -135,7 +133,7 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ
zaxReceiver.setAccount(receiverAccount);
zaxReceiver.setName(receiverName);
zaxReceiver.setAmount(amount);
zaxReceiver.setDescription(getDescription(mchPaySetting, amount));
zaxReceiver.setDescription(getDescription(mchPaySetting, relation.getFzRatio(), amount));
createDto.setReceivers(List.of(zaxReceiver));
createDto.setUnfreezeUnsplit(true);
fzOrderEntity.setPrice(BigDecimal.valueOf(amount).intValue());
@@ -192,8 +190,7 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ
MchPaySettingEntity mchPaySetting = JSONUtil.toBean(orderEntity.getMchPaySettingJson(), MchPaySettingEntity.class);
MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean(mchPaySetting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class);
ThirdMchFzRelationEntity relation = mchFzRelationService == null ? null
: mchFzRelationService.getById(orderEntity.getFzRelationId());
ThirdMchFzRelationEntity relation = mchFzRelationService.getById(orderEntity.getFzRelationId());
requireWechatRelation(relation, orderEntity, wechatPaySetting.getSubMchid(), appPlusPayConfig.getWechatConfig());
String outOrderNo = "refund_" + fzOrderDetail.getFzNo();
@@ -204,7 +201,7 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ
returnDto.setOutReturnNo(outOrderNo);
returnDto.setReturnMchid(relation.getReceiverAccount());
returnDto.setAmount((long) fzOrderDetail.getPrice());
returnDto.setDescription("退回" + getDescription(mchPaySetting, returnDto.getAmount()));
returnDto.setDescription("退回" + getDescription(mchPaySetting, relation.getFzRatio(), returnDto.getAmount()));
ThirdFzOrderEntity updateObj = new ThirdFzOrderEntity();
updateObj.setFzRefundTime(LocalDateTime.now());
@@ -607,8 +604,8 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ
}
// 分账描述,最多80个字
private String getDescription(MchPaySettingEntity mchPaySetting, Long amount) {
return "商户id:" + mchPaySetting.getMchId() + "分账" + mchPaySetting.getFzRatio() + "%即" + fenToYuan(BigDecimal.valueOf(amount)) + "元,到平台";
private String getDescription(MchPaySettingEntity mchPaySetting, Double fzRatio, Long amount) {
return "商户id:" + mchPaySetting.getMchId() + "分账" + fzRatio + "%即" + fenToYuan(BigDecimal.valueOf(amount)) + "元,到平台";
}
public String fenToYuan(BigDecimal fen) {
@@ -24,8 +24,7 @@ import java.util.List;
*/
@Service
@Validated
public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl<ThirdAggregatePaySessionMapper, ThirdAggregatePaySessionEntity>
implements ThirdAggregatePaySessionService {
public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl<ThirdAggregatePaySessionMapper, ThirdAggregatePaySessionEntity> implements ThirdAggregatePaySessionService {
/**
* 按聚合会话编号读取当前租户有效记录。
@@ -76,7 +75,7 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl<ThirdAggreg
* 查询同业务最新聚合会话,供创建幂等和跨二维码互斥使用。
*
* @param bizType 支付业务类型
* @param bizId 调用方业务订单唯一标识
* @param bizId 调用方业务订单唯一标识
* @return 按创建时间倒序的第一条记录;参数无效或不存在时返回 {@code null}
*/
@Override
@@ -94,9 +93,9 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl<ThirdAggreg
/**
* 使用等待扫码状态作为 CAS 条件锁定平台和付款人,防止支付宝、微信同时创建可支付订单。
*
* @param sessionId 聚合会话主键
* @param platform 首个扫码选定的平台
* @param payNo 预分配的第三方子订单号
* @param sessionId 聚合会话主键
* @param platform 首个扫码选定的平台
* @param payNo 预分配的第三方子订单号
* @param payerIdentityHash 付款人身份摘要
* @return 仅一个竞争请求会返回 {@code true}
*/
@@ -121,7 +120,7 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl<ThirdAggreg
* 在第三方子订单保存成功后把会话从创建中推进为支付中。
*
* @param sessionId 聚合会话主键
* @param payNo 已成功保存的第三方子订单号
* @param payNo 已成功保存的第三方子订单号
* @return CAS 更新成功时返回 {@code true}
*/
@Override
@@ -138,7 +137,7 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl<ThirdAggreg
* 将明确失败的平台创建过程推进为创建失败终态。
*
* @param sessionId 聚合会话主键
* @param payNo 创建失败但已预分配的第三方子订单号
* @param payNo 创建失败但已预分配的第三方子订单号
* @return CAS 更新成功时返回 {@code true}
*/
@Override
@@ -159,7 +158,7 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl<ThirdAggreg
* 仅用于恢复已经验签并完成关键字段校验的延迟成功回调,确保真实扣款事实优先于本地超时判断。</p>
*
* @param sessionId 聚合会话主键
* @param status 目标终态
* @param status 目标终态
* @return CAS 更新成功时返回 {@code true}
*/
@Override
@@ -18,13 +18,14 @@ import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayOrderDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayOrderMapper;
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskMapper;
import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayOrderVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import lombok.RequiredArgsConstructor;
@@ -66,6 +67,10 @@ public class ThirdPayOrderServiceImpl extends ServiceImpl<ThirdPayOrderMapper, T
@Autowired(required = false)
private ThirdPaymentTaskMapper thirdPaymentTaskMapper;
/** 分账关系服务,用于按订单固定关系展示支付宝直付通二级商户标识。 */
@Autowired(required = false)
private ThirdMchFzRelationService thirdMchFzRelationService;
/**
* 按数据权限分页查询支付订单,并补充只用于管理端展示的关联信息。
*
@@ -182,25 +187,21 @@ public class ThirdPayOrderServiceImpl extends ServiceImpl<ThirdPayOrderMapper, T
}
/**
* 从订单创建时的支付宝配置快照读取直付通二级商户标识,不读取当前可变配置。
* 从订单创建时固定的关系事实读取直付通二级商户标识,不读取支付配置 JSON。
*
* @param order 支付订单视图
* @return 支付宝 smid;非支付宝订单、历史快照缺失或格式错误时为空
*/
private String readAlipaySmid(ThirdPayOrderVo order) {
if (!ObjectUtil.equals(order.getPayPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY.getType())
|| StrUtil.isBlank(order.getMchPaySettingJson())) {
|| order.getFzRelationId() == null || thirdMchFzRelationService == null) {
return null;
}
try {
MchPaySettingEntity setting = JSONUtil.toBean(order.getMchPaySettingJson(), MchPaySettingEntity.class);
if (setting == null || StrUtil.isBlank(setting.getSettingJson())) {
return null;
}
MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
return aliPaySetting == null ? null : aliPaySetting.getSubMchid();
ThirdMchFzRelationEntity relation = thirdMchFzRelationService.getById(order.getFzRelationId());
return relation == null ? null : relation.getChannelSubMchId();
} catch (Exception exception) {
log.warn("支付订单支付宝商户快照解析失败,payNo:{},errorType:{}", order.getPayNo(),
log.warn("支付订单支付宝关系读取失败,payNo:{},errorType:{}", order.getPayNo(),
exception.getClass().getSimpleName());
return null;
}
@@ -20,8 +20,8 @@ class ThirdPaymentTaskMapperSqlContractTest {
/** MyBatis 支付订单映射资源路径,支付订单必须回读创建时固定的分账关系主键。 */
private static final String PAY_ORDER_MAPPER_RESOURCE = "mapper/ThirdPayOrderMapper.xml";
/** 支付任务表与高频业务扫描索引迁移相对项目根目录的路径。 */
private static final String PAYMENT_TASK_MIGRATION = "db/20260824_add_third_payment_task.sql";
/** thirdparty 支付统一迁移与高频业务扫描索引相对项目根目录的路径。 */
private static final String PAYMENT_TASK_MIGRATION = "db/20260825_third_payment_schema.sql";
/** 测试启动时读取的 Mapper XML。 */
private static String mapperXml;
@@ -16,6 +16,7 @@ import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import org.junit.jupiter.api.Assertions;
@@ -25,6 +26,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
@@ -58,6 +60,10 @@ class ThirdAggregateAliPayServiceImplTest {
@Mock
private ThirdAppPlusAliPayService thirdAppPlusAliPayService;
/** 支付宝聚合子订单关系校验服务 Mock。 */
@Mock
private ThirdMchFzRelationService mchFzRelationService;
/**
* 被测支付宝聚合服务,通过子类固定返回 Mock SDK 客户端。
*/
@@ -88,6 +94,7 @@ class ThirdAggregateAliPayServiceImplTest {
appPlusPayConfig.setAliPayConfig(aliPayConfig);
service = new TestableThirdAggregateAliPayService(appPlusPayConfig, thirdPayOrderService,
thirdAppPlusAliPayService, alipayClient);
ReflectionTestUtils.setField(service, "mchFzRelationService", mchFzRelationService);
context = new ThirdAggregatePayCreateContext();
context.setPayNo("AGP202608050001");
@@ -185,13 +192,15 @@ class ThirdAggregateAliPayServiceImplTest {
}
/**
* 启用平台抽佣但编排层未固定关系主键时,底层不得向支付宝创建交易。
* 编排层携带的关系未绑定时,底层不得向支付宝创建交易。
*
* @throws Exception Mockito 校验的支付宝 SDK 方法声明受检异常
*/
@Test
void createPayShouldRejectCommissionWithoutRelationId() throws Exception {
paySetting.setFzRatio(5D);
void createPayShouldRejectUnboundCommissionRelation() throws Exception {
context.setFzRelationId(9001L);
when(mchFzRelationService.requirePaymentRelation(9001L, paySetting))
.thenThrow(new IllegalStateException("支付宝直付通平台分佣关系尚未绑定"));
RuntimeException exception = Assertions.assertThrows(RuntimeException.class,
() -> service.createPay(context, paySetting,
@@ -30,6 +30,7 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationStatusEnum;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
@@ -116,18 +117,15 @@ class ThirdAppPlusAliPayServiceImplTest {
aliPayConfig.setBaseCallbackUrl("https://pay.example.com");
aliPayConfig.setPayCallbackUrl("/third-app-pay/app/pay-callback");
aliPayConfig.setRefundCallbackUrl("/third-app-pay/app/refund-callback");
aliPayConfig.setRoyaltyTransIn("2088000000000001");
aliPayConfig.setRoyaltyTransInType("userId");
aliPayConfig.setRoyaltyTransInName("资安信平台");
appPlusPayConfig.setAliPayConfig(aliPayConfig);
MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting();
aliPaySetting.setAppAuthToken("merchant-auth-token");
aliPaySetting.setSubMchid("smid-1001");
paySetting = new MchPaySettingVo();
paySetting.setId(102L);
paySetting.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType());
paySetting.setEnable(YesNoEnum.YES.getCode());
paySetting.setFzRatio(10D);
paySetting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting));
payDto = new ThirdAppPlusPayDto();
@@ -198,6 +196,21 @@ class ThirdAppPlusAliPayServiceImplTest {
Assertions.assertFalse(requestCaptor.getValue().getTextParams().containsKey("app_auth_token"));
}
/** 非当前默认关系即使已经绑定也不能用于创建新支付,避免内部调用伪造关系主键。 */
@Test
void appPayShouldRejectBoundButNonDefaultRelation() throws Exception {
ThirdMchFzRelationEntity relation = boundAlipayRelation();
relation.setIsDefault(YesNoEnum.NO.getCode());
when(mchFzRelationService.getById(payDto.getFzRelationId())).thenReturn(relation);
IllegalStateException exception = Assertions.assertThrows(IllegalStateException.class,
() -> service.appPay(payDto, paySetting));
Assertions.assertTrue(exception.getMessage().contains("默认且已绑定"));
verify(alipayClient, never()).sdkExecute(any(AlipayTradeAppPayRequest.class));
verify(thirdPayOrderService, never()).save(any(ThirdPayOrderEntity.class));
}
/**
* 验证支付宝明确拒绝商户授权时结束直付通分账,不会降级调用普通平台抽佣接口。
*/
@@ -340,6 +353,20 @@ class ThirdAppPlusAliPayServiceImplTest {
verify(thirdFzOrderService).save(result);
}
/** 低比例向下取整为零时,支付宝分账事实必须按渠道下限保存为一分钱。 */
@Test
void ensureDirectPayFzOrderShouldUseOneFenMinimumAmount() {
ThirdPayOrderEntity payOrder = paidDirectPayOrder();
ThirdMchFzRelationEntity relation = boundAlipayRelation();
relation.setFzRatio(0.001D);
when(mchFzRelationService.getById(payOrder.getFzRelationId())).thenReturn(relation);
ThirdFzOrderEntity result = service.ensureDirectPayFzOrder(payOrder);
Assertions.assertEquals(1, result.getPrice());
verify(thirdFzOrderService).save(result);
}
/**
* 验证已完成平台抽佣的支付宝订单退款携带按退款金额折算的 refund_royalty_parameters。
*/
@@ -629,13 +656,17 @@ class ThirdAppPlusAliPayServiceImplTest {
ThirdMchFzRelationEntity relation = new ThirdMchFzRelationEntity();
relation.setId(payDto.getFzRelationId());
relation.setMchId(payDto.getMchId());
relation.setPaySettingId(paySetting.getId());
relation.setPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType());
relation.setChannelAppId(appPlusPayConfig.getAliPayConfig().getAppId());
relation.setChannelSubMchId("smid-1001");
relation.setReceiverType("userId");
relation.setReceiverAccount("2088000000000001");
relation.setReceiverName("资安信平台");
relation.setFzRatio(10D);
relation.setBoundTime(LocalDateTime.now().minusDays(1));
relation.setStatus(ThirdMchFzRelationStatusEnum.BOUND.getCode());
relation.setIsDefault(YesNoEnum.YES.getCode());
relation.setDelFlag("0");
return relation;
}
@@ -657,7 +657,6 @@ class ThirdAppPlusServiceImplOrderCodePayTest {
MchPaySettingVo setting = new MchPaySettingVo();
setting.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType());
setting.setEnable(YesNoEnum.YES.getCode());
setting.setFzRatio(10D);
setting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting));
MchManageVo result = new MchManageVo();
result.setId(88L);
@@ -96,7 +96,6 @@ class ThirdAppPlusWechatFzServiceImplTest {
MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting();
wechatPaySetting.setSubMchid("wx-sub-1001");
MchPaySettingEntity setting = new MchPaySettingEntity();
setting.setFzRatio(10D);
setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting));
payOrder = new ThirdPayOrderEntity();
payOrder.setId(1L);
@@ -131,6 +130,25 @@ class ThirdAppPlusWechatFzServiceImplTest {
Assertions.assertEquals("1900000002", requestCaptor.getValue().getReceivers().get(0).getAccount());
}
/** 低比例向下取整为零时,微信分账请求必须按渠道下限提交一分钱。 */
@Test
void createShouldUseOneFenMinimumAmount() {
payOrder.setPrice(11);
ThirdMchFzRelationEntity relation = boundWechatRelation();
relation.setFzRatio(0.001D);
when(appPlusPayConfig.getWechatConfig()).thenReturn(wechatConfig());
when(mchFzRelationService.getById(payOrder.getFzRelationId())).thenReturn(relation);
OrdersEntity response = new OrdersEntity();
response.setState(OrderStatus.PROCESSING);
when(profitsharingService.createOrder(any(CreateOrderRequest.class))).thenReturn(response);
service.create(payOrder);
ArgumentCaptor<CreateOrderRequest> requestCaptor = ArgumentCaptor.forClass(CreateOrderRequest.class);
verify(profitsharingService).createOrder(requestCaptor.capture());
Assertions.assertEquals(1L, requestCaptor.getValue().getReceivers().get(0).getAmount());
}
/**
* 验证固定关系缺失时微信分账拒绝调用渠道,不会回退到当前服务商商户号。
*/
@@ -273,6 +291,7 @@ class ThirdAppPlusWechatFzServiceImplTest {
relation.setReceiverType("MERCHANT_ID");
relation.setReceiverAccount("1900000002");
relation.setReceiverName("平台分佣接收方");
relation.setFzRatio(10D);
relation.setBoundTime(LocalDateTime.now().minusDays(1));
relation.setDelFlag("0");
return relation;
@@ -11,6 +11,7 @@ import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.impl.appplus.fz.lifecycle.ThirdAppPlusWechatFzLifecycle;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
@@ -67,6 +68,10 @@ class ThirdAppPlusWechatServiceImplPayCallbackTest {
@Mock
private ThirdAppPlusWechatFzLifecycle appPlusWechatFzLifecycle;
/** 分账关系服务 Mock,仅补齐微信支付服务的安全前置依赖。 */
@Mock
private ThirdMchFzRelationService mchFzRelationService;
/**
* 聚合支付会话服务 Mock,用于严格区分旧 Native 和新 JSAPI 子订单回调。
*/
@@ -8,8 +8,10 @@ import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdMiniWechatRefundDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.service.impl.appplus.fz.lifecycle.ThirdAppPlusWechatFzLifecycle;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.wechat.ThirdMiniWechatRefundQueryVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
@@ -76,6 +78,10 @@ class ThirdAppPlusWechatServiceImplRefundTest {
@Mock
private ThirdAppPlusWechatFzLifecycle appPlusWechatFzLifecycle;
/** 分账关系服务 Mock,用于验证关系表中的非法比例会在微信预下单前被拒绝。 */
@Mock
private ThirdMchFzRelationService mchFzRelationService;
/** 被测微信 App 支付服务。 */
@InjectMocks
private ThirdAppPlusWechatServiceImpl service;
@@ -145,19 +151,21 @@ class ThirdAppPlusWechatServiceImplRefundTest {
}
/**
* 验证非法微信分账比例在创建渠道支付前被拒绝,不会留下无法完成抽佣的已支付交易。
* 验证关系服务判定抽佣比例非法时在渠道支付前拒绝,不会留下无法完成抽佣的已支付交易。
*/
@Test
void appPayShouldRejectInvalidProfitSharingRatioBeforeCreatingOrder() {
MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting();
wechatPaySetting.setSubMchid("wx-sub-1001");
MchPaySettingVo setting = new MchPaySettingVo();
setting.setFzRatio(101D);
setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting));
ThirdAppPlusPayDto dto = new ThirdAppPlusPayDto();
dto.setAmount(1000);
dto.setOutTradeNo("pay-invalid-ratio");
dto.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY);
dto.setFzRelationId(9001L);
when(mchFzRelationService.requirePaymentRelation(9001L, setting))
.thenThrow(new IllegalStateException("微信平台抽佣比例必须大于0且不超过100%"));
RuntimeException exception = Assertions.assertThrows(RuntimeException.class,
() -> service.appPay(dto, setting));
@@ -167,6 +175,38 @@ class ThirdAppPlusWechatServiceImplRefundTest {
verify(thirdPayOrderService, org.mockito.Mockito.never()).save(any(ThirdPayOrderEntity.class));
}
/** 低比例计算结果不足一分钱时,微信预下单校验应采用一分钱下限而不是拒绝支付。 */
@Test
void appPayShouldAcceptRatioWhoseCalculatedAmountIsBelowOneFen() {
AppPlusPayConfig.WechatConfig config = new AppPlusPayConfig.WechatConfig();
config.setAppPlatformAppId("wx-app-id");
config.setMerchantId("wx-service-mch");
config.setBaseCallbackUrl("https://pay.example.com");
config.setPayCallbackUrl("/pay-callback");
when(appPlusPayConfig.getWechatConfig()).thenReturn(config);
PrepayWithRequestPaymentResponse response = new PrepayWithRequestPaymentResponse();
response.setPrepayId("prepay-id");
when(appServiceExtension.prepayWithRequestPayment(any(PrepayRequest.class), any())).thenReturn(response);
MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting();
wechatPaySetting.setSubMchid("wx-sub-1001");
MchPaySettingVo setting = new MchPaySettingVo();
setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting));
ThirdAppPlusPayDto dto = new ThirdAppPlusPayDto();
dto.setAmount(11);
dto.setOutTradeNo("pay-minimum-fz-amount");
dto.setBizId("order-minimum-fz-amount");
dto.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY);
dto.setMchId(88L);
dto.setFzRelationId(9001L);
ThirdMchFzRelationEntity relation = new ThirdMchFzRelationEntity();
relation.setFzRatio(0.001D);
when(mchFzRelationService.requirePaymentRelation(9001L, setting)).thenReturn(relation);
service.appPay(dto, setting);
verify(appServiceExtension).prepayWithRequestPayment(any(PrepayRequest.class), any());
}
/**
* 验证微信 App 预下单把本地保存的十五分钟截止时间同步传给渠道,避免本地和微信订单生命周期分叉。
*/
@@ -2,6 +2,8 @@ package com.bsd.pjcloud.thirdparty.service.impl.pay;
import cn.hutool.json.JSONUtil;
import com.alipay.api.AlipayClient;
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;
@@ -10,9 +12,12 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationStatusEnum;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType;
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdMchFzRelationSaveDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdMchFzRelationMapper;
import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer;
@@ -25,6 +30,7 @@ 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 org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -32,14 +38,11 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.springframework.dao.DuplicateKeyException;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -97,108 +100,59 @@ class ThirdMchFzRelationServiceImplTest {
alipay.setAppId("2026000000000001");
alipay.setAppPrivateKey("test-private-key");
alipay.setAlipayPublicKey("test-public-key");
alipay.setRoyaltyTransInType("userId");
alipay.setRoyaltyTransIn("2088000000000001");
alipay.setRoyaltyTransInName("平台支付宝账号");
config.setAliPayConfig(alipay);
service = new TestableThirdMchFzRelationServiceImpl(relationMapper, taskEnqueuer,
profitsharingService, config, remoteMchService, alipayClient);
TenantContextHolder.setTenantId(TENANT_ID);
}
/** 微信和支付宝启用抽佣后分别生成一条默认关系,且请求快照不保存授权 Token。 */
/** 清理测试线程租户,防止同进程其他用例继承本类上下文。 */
@AfterEach
void tearDown() {
TenantContextHolder.clear();
}
/** 支付配置同步只处理关系生命周期,不再从支付配置 JSON 自动创建渠道关系。 */
@Test
void syncDefaultRelationsShouldCreateWechatAndAlipayRelations() {
when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(null);
void syncDefaultRelationsShouldNotCreateRelationsFromSettingJson() {
MchPaySettingVo wechat = wechatSetting(101L, 3D);
MchPaySettingVo alipay = alipaySetting(102L, 5D, "sensitive-auth-token");
service.syncDefaultRelations(merchant(List.of(wechat, alipay)));
ArgumentCaptor<ThirdMchFzRelationEntity> captor = ArgumentCaptor.forClass(ThirdMchFzRelationEntity.class);
verify(relationMapper, times(2)).insert(captor.capture());
List<ThirdMchFzRelationEntity> relations = captor.getAllValues();
ThirdMchFzRelationEntity wechatRelation = relations.stream()
.filter(item -> AppPlusPlatformPayType.WECHAT.getType().equals(item.getPlatformType()))
.findFirst().orElseThrow();
ThirdMchFzRelationEntity alipayRelation = relations.stream()
.filter(item -> AppPlusPlatformPayType.ALI_PAY.getType().equals(item.getPlatformType()))
.findFirst().orElseThrow();
Assertions.assertAll(
() -> Assertions.assertEquals("wx-sub-1001", wechatRelation.getChannelSubMchId()),
() -> Assertions.assertEquals("1900000002", wechatRelation.getReceiverAccount()),
() -> Assertions.assertEquals(ReceiverType.MERCHANT_ID.name(), wechatRelation.getReceiverType()),
() -> Assertions.assertEquals(ReceiverRelationType.SERVICE_PROVIDER.name(), wechatRelation.getChannelRelationType()),
() -> Assertions.assertEquals("smid-1001", alipayRelation.getChannelSubMchId()),
() -> Assertions.assertEquals("2088000000000001", alipayRelation.getReceiverAccount()),
() -> Assertions.assertEquals("userId", alipayRelation.getReceiverType()),
() -> Assertions.assertFalse(alipayRelation.getBindReq().contains("sensitive-auth-token")),
() -> Assertions.assertTrue(alipayRelation.getBindReq().contains("appAuthTokenConfigured")));
}
/** 相同关系身份重复同步时复用既有记录,不重复插入渠道关系事实。 */
@Test
void syncDefaultRelationsShouldReuseExistingIdentity() {
MchPaySettingVo setting = wechatSetting(101L, 3D);
when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(null);
service.syncDefaultRelations(merchant(List.of(setting)));
ArgumentCaptor<ThirdMchFzRelationEntity> captor = ArgumentCaptor.forClass(ThirdMchFzRelationEntity.class);
verify(relationMapper).insert(captor.capture());
ThirdMchFzRelationEntity existing = captor.getValue();
existing.setStatus(ThirdMchFzRelationStatusEnum.BOUND.getCode());
existing.setBoundTime(LocalDateTime.now());
clearInvocations(relationMapper, taskEnqueuer);
when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(existing);
service.syncDefaultRelations(merchant(List.of(setting)));
verify(relationMapper, never()).insert(any(ThirdMchFzRelationEntity.class));
verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt());
}
/** 并发插入命中唯一键时读取胜出记录继续处理,不把正常竞争暴露为配置保存失败。 */
@Test
void syncDefaultRelationsShouldReuseWinnerAfterDuplicateKey() {
ThirdMchFzRelationEntity winner = pendingWechatRelation(7001L);
when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(null, winner);
doThrow(new DuplicateKeyException("duplicate relation")).when(relationMapper)
.insert(any(ThirdMchFzRelationEntity.class));
service.syncDefaultRelations(merchant(List.of(wechatSetting(101L, 3D))));
verify(taskEnqueuer).enqueueRelationBind(org.mockito.ArgumentMatchers.eq(TENANT_ID),
org.mockito.ArgumentMatchers.eq(7001L), any(), org.mockito.ArgumentMatchers.eq(150));
}
/** 关系尚未绑定时支付入口必须拒绝,并只确保稳定任务存在而不重置既有退避。 */
/** 正比例默认关系尚未绑定时支付入口必须拒绝,并只确保稳定任务存在而不重置既有退避。 */
@Test
void requireBoundShouldRejectAndEnsureTaskWithoutRearm() {
when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(null).thenReturn(null);
ThirdMchFzRelationEntity pending = pendingWechatRelation(7005L);
pending.setIsDefault(YesNoEnum.YES.getCode());
when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(pending);
IllegalStateException exception = Assertions.assertThrows(IllegalStateException.class,
() -> service.requireBound(wechatSetting(101L, 3D)));
Assertions.assertTrue(exception.getMessage().contains("尚未绑定"));
ArgumentCaptor<ThirdMchFzRelationEntity> relationCaptor = ArgumentCaptor.forClass(ThirdMchFzRelationEntity.class);
verify(relationMapper).insert(relationCaptor.capture());
verify(taskEnqueuer).ensureRelationBind(org.mockito.ArgumentMatchers.eq(TENANT_ID),
org.mockito.ArgumentMatchers.eq(relationCaptor.getValue().getId()), any(), org.mockito.ArgumentMatchers.eq(150));
org.mockito.ArgumentMatchers.eq(pending.getId()), any(), org.mockito.ArgumentMatchers.eq(150));
verify(relationMapper, never()).insert(any(ThirdMchFzRelationEntity.class));
verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt());
}
/** 跨服务保存回滚留下的历史已绑定关系应按当前配置恢复,避免支付永久被拒绝。 */
/** 零比例关系不满足支付前置条件,不能绕过关系表抽佣规则继续下单。 */
@Test
void requireBoundShouldRestoreMatchingHistoricalBoundRelation() {
ThirdMchFzRelationEntity historical = pendingWechatRelation(7004L);
historical.setStatus(ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode());
historical.setIsDefault(YesNoEnum.NO.getCode());
historical.setBoundTime(LocalDateTime.now());
when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(null, historical);
void requireBoundShouldRejectWhenDefaultRelationRatioIsZero() {
ThirdMchFzRelationEntity disabled = pendingWechatRelation(7004L);
disabled.setFzRatio(0D);
disabled.setIsDefault(YesNoEnum.YES.getCode());
when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(disabled);
ThirdMchFzRelationEntity result = service.requireBound(wechatSetting(101L, 3D));
IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class,
() -> service.requireBound(wechatSetting(101L, 3D)));
Assertions.assertSame(historical, result);
Assertions.assertEquals(ThirdMchFzRelationStatusEnum.BOUND.getCode(), result.getStatus());
Assertions.assertEquals(YesNoEnum.YES.getCode(), result.getIsDefault());
Assertions.assertTrue(exception.getMessage().contains("大于0"));
verify(taskEnqueuer, never()).ensureRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt());
verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt());
}
@@ -230,9 +184,6 @@ class ThirdMchFzRelationServiceImplTest {
binding.setStatus(ThirdMchFzRelationStatusEnum.BINDING.getCode());
when(relationMapper.selectById(7003L)).thenReturn(pending, binding);
when(relationMapper.update(any(), any())).thenReturn(1);
when(remoteMchService.getById(MCH_ID)).thenReturn(com.bsd.pjcloud.common.core.util.R.ok(
merchant(List.of(alipaySetting(102L, 5D, "merchant-auth-token")))));
AlipayTradeRoyaltyRelationBindResponse bindResponse = new AlipayTradeRoyaltyRelationBindResponse();
bindResponse.setCode("10000");
bindResponse.setResultCode("FAIL");
@@ -246,14 +197,145 @@ class ThirdMchFzRelationServiceImplTest {
verify(relationMapper, times(2)).update(any(), any());
}
/** 负数或超过百分之百的抽佣配置必须在关系物化前拒绝。 */
/** 支付宝 openId 关系必须写入 account_open_id,不能错误写入 account 或 login_name。 */
@Test
void syncDefaultRelationsShouldRejectInvalidRatio() {
Assertions.assertThrows(IllegalArgumentException.class,
() -> service.syncDefaultRelations(merchant(List.of(wechatSetting(101L, -1D)))));
Assertions.assertThrows(IllegalArgumentException.class,
() -> service.syncDefaultRelations(merchant(List.of(alipaySetting(102L, 101D, null)))));
void bindAlipayShouldMapOpenIdToAccountOpenId() throws Exception {
RoyaltyEntity receiver = bindAlipayAndCaptureReceiver("openId", "alipay-open-id-1001");
Assertions.assertAll(
() -> Assertions.assertEquals("openId", receiver.getType()),
() -> Assertions.assertEquals("alipay-open-id-1001", receiver.getAccountOpenId()),
() -> Assertions.assertNull(receiver.getAccount()),
() -> Assertions.assertNull(receiver.getLoginName()));
}
/** 支付宝卡编号关系使用 account 承载 cardAliasNo,类型字段明确区分于 userId。 */
@Test
void bindAlipayShouldMapCardAliasNoToAccount() throws Exception {
RoyaltyEntity receiver = bindAlipayAndCaptureReceiver("cardAliasNo", "card-alias-1001");
Assertions.assertAll(
() -> Assertions.assertEquals("cardAliasNo", receiver.getType()),
() -> Assertions.assertEquals("card-alias-1001", receiver.getAccount()),
() -> Assertions.assertNull(receiver.getAccountOpenId()),
() -> Assertions.assertNull(receiver.getLoginName()));
}
/** 修改接收方账号必须创建新关系版本,旧关系仅退出默认候选并保留给历史订单。 */
@Test
void saveForManageShouldVersionRelationWhenReceiverAccountChanges() {
MchPaySettingVo setting = wechatSetting(101L, 3D);
ThirdMchFzRelationEntity existing = boundWechatRelation(7006L);
when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting))));
when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(existing);
when(relationMapper.update(any(), any())).thenReturn(1);
ThirdMchFzRelationSaveDto dto = manageEditDto(existing);
dto.setReceiverAccount("1900000099");
var result = service.saveForManage(dto);
ArgumentCaptor<ThirdMchFzRelationEntity> relationCaptor = ArgumentCaptor.forClass(ThirdMchFzRelationEntity.class);
verify(relationMapper).insert(relationCaptor.capture());
ThirdMchFzRelationEntity replacement = relationCaptor.getValue();
Assertions.assertAll(
() -> Assertions.assertNotEquals(existing.getId(), replacement.getId()),
() -> Assertions.assertEquals(replacement.getId(), result.getId()),
() -> Assertions.assertEquals("1900000099", replacement.getReceiverAccount()),
() -> Assertions.assertEquals(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode(), replacement.getStatus()),
() -> Assertions.assertEquals(ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode(), existing.getStatus()),
() -> Assertions.assertEquals(YesNoEnum.NO.getCode(), existing.getIsDefault()));
verify(taskEnqueuer).enqueueRelationBind(org.mockito.ArgumentMatchers.eq(TENANT_ID),
org.mockito.ArgumentMatchers.eq(replacement.getId()), any(), org.mockito.ArgumentMatchers.eq(150));
}
/** 修改接收方名称不改变历史接收账号,但必须清空旧结果并唤醒原关系绑定任务。 */
@Test
void saveForManageShouldRebindCurrentRelationWhenReceiverNameChanges() {
MchPaySettingVo setting = wechatSetting(101L, 3D);
ThirdMchFzRelationEntity existing = boundWechatRelation(7007L);
when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting))));
when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(existing);
when(relationMapper.selectCount(any(Wrapper.class))).thenReturn(1L);
when(relationMapper.update(any(), any())).thenReturn(1);
ThirdMchFzRelationSaveDto dto = manageEditDto(existing);
dto.setReceiverName("新的平台名称");
var result = service.saveForManage(dto);
Assertions.assertAll(
() -> Assertions.assertEquals(existing.getId(), result.getId()),
() -> Assertions.assertEquals("新的平台名称", result.getReceiverName()),
() -> Assertions.assertEquals(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode(), result.getStatus()),
() -> Assertions.assertNull(result.getLastExternalStatus()),
() -> Assertions.assertNull(result.getBoundTime()));
verify(relationMapper, never()).insert(any(ThirdMchFzRelationEntity.class));
verify(taskEnqueuer).enqueueRelationBind(org.mockito.ArgumentMatchers.eq(TENANT_ID),
org.mockito.ArgumentMatchers.eq(existing.getId()), any(), org.mockito.ArgumentMatchers.eq(150));
}
/** 只修改抽佣比例属于本地结算规则,不需要重复调用渠道关系绑定接口。 */
@Test
void saveForManageShouldNotRebindWhenOnlyRatioChanges() {
MchPaySettingVo setting = wechatSetting(101L, 3D);
ThirdMchFzRelationEntity existing = boundWechatRelation(7008L);
when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting))));
when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(existing);
when(relationMapper.selectCount(any(Wrapper.class))).thenReturn(1L);
when(relationMapper.update(any(), any())).thenReturn(1);
ThirdMchFzRelationSaveDto dto = manageEditDto(existing);
dto.setFzRatio(2.125D);
var result = service.saveForManage(dto);
Assertions.assertAll(
() -> Assertions.assertEquals(2.125D, result.getFzRatio()),
() -> Assertions.assertEquals(ThirdMchFzRelationStatusEnum.BOUND.getCode(), result.getStatus()));
verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt());
}
/** 接收方类型变化时不能沿用旧类型的隐藏账号,管理端必须提交完整新标识。 */
@Test
void saveForManageShouldRejectTypeChangeWithoutFullAccount() {
MchPaySettingVo setting = wechatSetting(101L, 3D);
ThirdMchFzRelationEntity existing = boundWechatRelation(7009L);
when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting))));
when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(existing);
ThirdMchFzRelationSaveDto dto = manageEditDto(existing);
dto.setReceiverType("PERSONAL_OPENID");
IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class,
() -> service.saveForManage(dto));
Assertions.assertTrue(exception.getMessage().contains("完整的新接收方标识"));
verify(relationMapper, never()).insert(any(ThirdMchFzRelationEntity.class));
verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt());
}
/** 新建支付宝关系的绑定幂等号必须满足支付宝32字符限制,并保留可审计的平台前缀。 */
@Test
void saveForManageShouldGenerateShortAlipayOutRequestNo() {
MchPaySettingVo setting = alipaySetting(102L, 5D, "merchant-auth-token");
when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting))));
when(relationMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
ThirdMchFzRelationSaveDto dto = new ThirdMchFzRelationSaveDto();
dto.setMchId(MCH_ID);
dto.setPaySettingId(setting.getId());
dto.setChannelSubMchId("smid-1001");
dto.setReceiverType("userId");
dto.setReceiverAccount("2088000000000001");
dto.setReceiverName("平台支付宝账号");
dto.setFzRatio(5D);
ArgumentCaptor<ThirdMchFzRelationEntity> relationCaptor = ArgumentCaptor.forClass(ThirdMchFzRelationEntity.class);
when(relationMapper.insert(relationCaptor.capture())).thenReturn(1);
service.saveForManage(dto);
String outRequestNo = relationCaptor.getValue().getOutRequestNo();
Assertions.assertAll(
() -> Assertions.assertTrue(outRequestNo.startsWith("ar_")),
() -> Assertions.assertTrue(outRequestNo.length() <= 32),
() -> Assertions.assertTrue(outRequestNo.matches("[a-z0-9_]+")));
}
/** 构造商户关系同步快照。 */
@@ -265,14 +347,14 @@ class ThirdMchFzRelationServiceImplTest {
return merchant;
}
/** 构造启用抽佣的微信子商户支付配置。 */
/** 构造微信子商户支付配置;ratio 参数仅保留测试调用兼容,比例不再属于支付配置。 */
private MchPaySettingVo wechatSetting(Long id, Double ratio) {
MchPaySettingEntity.WechatPaySetting detail = new MchPaySettingEntity.WechatPaySetting();
detail.setSubMchid("wx-sub-1001");
return setting(id, AppPlusPlatformPayType.WECHAT.getType(), ratio, JSONUtil.toJsonStr(detail));
}
/** 构造启用抽佣的支付宝直付通支付配置。 */
/** 构造支付宝直付通支付配置;ratio 参数仅保留测试调用兼容,比例不再属于支付配置。 */
private MchPaySettingVo alipaySetting(Long id, Double ratio, String appAuthToken) {
MchPaySettingEntity.AliPaySetting detail = new MchPaySettingEntity.AliPaySetting();
detail.setSubMchid("smid-1001");
@@ -288,7 +370,6 @@ class ThirdMchFzRelationServiceImplTest {
setting.setMchId(MCH_ID);
setting.setPlatformType(platformType);
setting.setEnable(YesNoEnum.YES.getCode());
setting.setFzRatio(ratio);
setting.setSettingJson(settingJson);
return setting;
}
@@ -306,12 +387,42 @@ class ThirdMchFzRelationServiceImplTest {
relation.setReceiverType(ReceiverType.MERCHANT_ID.name());
relation.setReceiverAccount("1900000002");
relation.setReceiverName("平台服务商");
relation.setFzRatio(3D);
relation.setChannelRelationType(ReceiverRelationType.SERVICE_PROVIDER.name());
relation.setStatus(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode());
relation.setDelFlag("0");
return relation;
}
/** 构造已完成渠道绑定且正在承接新支付的微信关系。 */
private ThirdMchFzRelationEntity boundWechatRelation(Long id) {
ThirdMchFzRelationEntity relation = pendingWechatRelation(id);
relation.setOutRequestNo("wxrel_" + id);
relation.setStatus(ThirdMchFzRelationStatusEnum.BOUND.getCode());
relation.setIsDefault(YesNoEnum.YES.getCode());
relation.setBindRes("{\"success\":true}");
relation.setLastExternalStatus("SUCCESS");
relation.setLastQueryTime(LocalDateTime.now().minusMinutes(1));
relation.setBoundTime(LocalDateTime.now().minusMinutes(1));
return relation;
}
/** 构造编辑关系的管理端参数;接收方账号故意留空以模拟脱敏列表的真实提交方式。 */
private ThirdMchFzRelationSaveDto manageEditDto(ThirdMchFzRelationEntity relation) {
ThirdMchFzRelationSaveDto dto = new ThirdMchFzRelationSaveDto();
dto.setId(relation.getId());
dto.setMchId(relation.getMchId());
dto.setPaySettingId(relation.getPaySettingId());
dto.setChannelSubMchId(relation.getChannelSubMchId());
dto.setReceiverType(relation.getReceiverType());
dto.setReceiverAccount("");
dto.setReceiverName(relation.getReceiverName());
dto.setFzRatio(relation.getFzRatio());
dto.setChannelRelationType(relation.getChannelRelationType());
dto.setIsDefault(YesNoEnum.NO.getCode());
return dto;
}
/** 构造等待绑定的支付宝直付通平台分佣关系事实。 */
private ThirdMchFzRelationEntity pendingAlipayRelation(Long id) {
ThirdMchFzRelationEntity relation = new ThirdMchFzRelationEntity();
@@ -325,12 +436,38 @@ class ThirdMchFzRelationServiceImplTest {
relation.setReceiverType("userId");
relation.setReceiverAccount("2088000000000001");
relation.setReceiverName("平台支付宝账号");
relation.setFzRatio(5D);
relation.setAppAuthToken("merchant-auth-token");
relation.setOutRequestNo("alirel_7003");
relation.setStatus(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode());
relation.setDelFlag("0");
return relation;
}
/** 执行一次支付宝绑定并返回 SDK 请求中的接收方模型。 */
private RoyaltyEntity bindAlipayAndCaptureReceiver(String receiverType, String receiverAccount) throws Exception {
ThirdMchFzRelationEntity pending = pendingAlipayRelation(7003L);
pending.setReceiverType(receiverType);
pending.setReceiverAccount(receiverAccount);
ThirdMchFzRelationEntity binding = pendingAlipayRelation(7003L);
binding.setReceiverType(receiverType);
binding.setReceiverAccount(receiverAccount);
binding.setStatus(ThirdMchFzRelationStatusEnum.BINDING.getCode());
when(relationMapper.selectById(7003L)).thenReturn(pending, binding);
when(relationMapper.update(any(), any())).thenReturn(1);
AlipayTradeRoyaltyRelationBindResponse response = new AlipayTradeRoyaltyRelationBindResponse();
response.setCode("10000");
response.setResultCode("SUCCESS");
ArgumentCaptor<AlipayTradeRoyaltyRelationBindRequest> requestCaptor =
ArgumentCaptor.forClass(AlipayTradeRoyaltyRelationBindRequest.class);
when(alipayClient.execute(requestCaptor.capture())).thenReturn(response);
Assertions.assertTrue(service.bind(7003L));
AlipayTradeRoyaltyRelationBindModel model =
(AlipayTradeRoyaltyRelationBindModel) requestCaptor.getValue().getBizModel();
return model.getReceiverList().get(0);
}
/** 固定返回 Mock 支付宝客户端的关系服务测试实现。 */
private static final class TestableThirdMchFzRelationServiceImpl extends ThirdMchFzRelationServiceImpl {
@@ -67,8 +67,7 @@ public interface RemoteUserService {
* @return 含准确总数的轻量用户分页;调用方必须检查业务状态和空数据
*/
@PostMapping("/user/basic-page")
R<Page<UserVO>> getScopeBasicUserPage(@RequestParam("current") Long current, @RequestParam("size") Long size,
@RequestBody UserDTO userDTO);
R<Page<UserVO>> getScopeBasicUserPage(@RequestParam("current") Long current, @RequestParam("size") Long size, @RequestBody UserDTO userDTO);
/**
* 按当前登录人的数据权限分页查询导出所需的完整用户主资料和身份资料。
@@ -97,10 +97,14 @@ public class SysUserController {
private final SysUserService userService;
/** 用户 Excel 导入导出服务,统一承接用户模块的文件转换与导入编排。 */
/**
* 用户 Excel 导入导出服务,统一承接用户模块的文件转换与导入编排。
*/
private final SysUserImportExportService userImportExportService;
/** 用户 Excel 导出服务,独立承接模板填充、身份资料组装和结果文件保存。 */
/**
* 用户 Excel 导出服务,独立承接模板填充、身份资料组装和结果文件保存。
*/
private final SysUserExportService userExportService;
private final RemoterVisitorService remoterVisitorService;
@@ -110,7 +114,9 @@ public class SysUserController {
private final SysRoleService sysRoleService;
private final SysUserRoleService sysUserRoleService;
/** 学生身份服务,用于跨服务开卡导入按学校和学号精确定位学生。 */
/**
* 学生身份服务,用于跨服务开卡导入按学校和学号精确定位学生。
*/
private final SysUserStudentService sysUserStudentService;
private final SysMessageService sysMessageService;
/* ************************************* start 回调 start ************************************* */
@@ -162,7 +168,7 @@ public class SysUserController {
* 根据学校和学号精确查询学生用户,供钱包学生开卡导入校验使用。
*
* @param schoolName 学生身份扩展信息中的学校名称
* @param studentNo 学生身份扩展信息中的学号
* @param studentNo 学生身份扩展信息中的学号
* @return 唯一学生用户;不存在时返回失败响应
*/
@GetMapping("/get-student-by-school-and-no")
@@ -336,14 +342,15 @@ public class SysUserController {
*
* <p>该接口供无需返回角色、岗位、人脸和审批信息、但可能按人脸录入状态筛选的业务列表调用,避免复用管理端完整用户分页产生额外查询。</p>
*
* @param page 分页参数
* @param page 分页参数
* @param userDTO 用户关键字、部门树和指定用户筛选条件
* @return 包含用户总数、基础信息、部门和身份资料的轻量分页
*/
@ApiCryptoAes
@PostMapping("/basic-page")
public R<IPage<UserVO>> getBasicUserPage(@ParameterObject Page page, @RequestBody UserDTO userDTO) {
return R.ok(userService.getBasicUsersPage(page, userDTO, DataScope.of()));
DataScope dataScope = DataScope.of();
return R.ok(userService.getBasicUsersPage(page, userDTO, dataScope));
}
/**
@@ -351,7 +358,7 @@ public class SysUserController {
*
* <p>该接口供跨服务后台导出分批读取,返回账号主资料、角色岗位和身份扩展,跳过人脸及审批流程。</p>
*
* @param page 分页参数
* @param page 分页参数
* @param userDTO 用户关键字、部门树和指定用户筛选条件
* @return 包含用户总数和完整导出资料的分页
*/
@@ -467,11 +474,11 @@ public class SysUserController {
}
/**
* 导入后台用户基础信息,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。
* 导入后台用户基础信息,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。
*
* @param excelVOList Excel 中解析出的后台用户数据
* @param excelVOList Excel 中解析出的后台用户数据
* @param bindingResult Excel 通用校验产生的错误信息
* @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度
* @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度
*/
@PostMapping("/import")
@HasPermission("sys_user_import")
@@ -480,11 +487,11 @@ public class SysUserController {
}
/**
* 导入学生用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。
* 导入学生用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。
*
* @param studentUserExcelList Excel 中解析出的学生用户数据
* @param bindingResult Excel 通用校验产生的错误信息
* @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度
* @param bindingResult Excel 通用校验产生的错误信息
* @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度
*/
@PostMapping("/import-student")
@HasPermission("sys_student_user_export")
@@ -494,12 +501,12 @@ public class SysUserController {
}
/**
* 导入学生家长用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。
* 导入学生家长用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。
* <p>手机号和部门必填;姓名为空时按“手机号 + 学生家长”生成,手机号同时作为登录账号和初始密码。</p>
*
* @param guardianUserExcelList Excel 中解析出的学生家长用户数据
* @param bindingResult Excel 通用校验产生的错误信息
* @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度
* @param bindingResult Excel 通用校验产生的错误信息
* @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度
*/
@PostMapping("/import-guardian")
@HasPermission("sys_guardian_user_export")
@@ -509,12 +516,12 @@ public class SysUserController {
}
/**
* 导入老师用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。
* 导入老师用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。
* <p>模板中的手机号作为登录账号,学号写入老师扩展表,老师身份单独保存为 TEACHER。</p>
*
* @param teacherUserExcelList Excel 中解析出的老师用户数据
* @param bindingResult Excel 通用校验产生的错误信息
* @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度
* @param bindingResult Excel 通用校验产生的错误信息
* @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度
*/
@PostMapping("/import-teacher")
@HasPermission("sys_teacher_user_export")
@@ -524,12 +531,12 @@ public class SysUserController {
}
/**
* 导入用户及 Excel 中内嵌的人脸图片,接口同步校验并复制图片字节,校验通过后返回异步任务供调用端轮询。
* 导入用户及 Excel 中内嵌的人脸图片,接口同步校验并复制图片字节,校验通过后返回异步任务供调用端轮询。
*
* @param file 原始 Excel 文件,用于读取内嵌的人脸图片
* @param excelList Excel 中解析出的用户数据
* @param file 原始 Excel 文件,用于读取内嵌的人脸图片
* @param excelList Excel 中解析出的用户数据
* @param bindingResult Excel 通用校验产生的错误信息
* @return 校验失败时包含校验或图片处理错误;校验通过时包含异步任务 ID 和初始进度
* @return 校验失败时包含校验或图片处理错误;校验通过时包含异步任务 ID 和初始进度
*/
@PostMapping("/import-face")
@HasPermission("sys_user_import")
@@ -342,7 +342,8 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
*/
@Override
public IPage<UserVO> getUsersWithRolePage(Page page, UserDTO userDTO, DataScope dataScope) {
if (ObjectUtil.isNotEmpty(dataScope)) {
if (dataScope != null) {
// 用户查询只按所属部门做数据权限过滤,不能再用用户创建人限制可见范围。
dataScope.setScopeUserName(null);
}
IPage<UserVO> list = baseMapper.getUserVosPage(page, userDTO, dataScope);
@@ -378,8 +379,8 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
*/
@Override
public IPage<UserVO> getBasicUsersPage(Page page, UserDTO userDTO, DataScope dataScope) {
if (ObjectUtil.isNotEmpty(dataScope)) {
// 用户查询只按所属部门做数据权限过滤,不能再用用户创建人限制可见范围。
if (dataScope != null) {
// DataScope 继承 HashMap,不能使用集合非空判断;此查询只按所属部门过滤。
dataScope.setScopeUserName(null);
}
IPage<UserVO> list = baseMapper.getBasicUserPage(page, userDTO, dataScope);
@@ -401,7 +402,7 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
*/
@Override
public IPage<UserVO> getExportUsersPage(Page page, UserDTO userDTO, DataScope dataScope) {
if (ObjectUtil.isNotEmpty(dataScope)) {
if (dataScope != null) {
// 导出与列表使用相同的部门数据权限口径,不按创建人额外缩小用户范围。
dataScope.setScopeUserName(null);
}
@@ -56,12 +56,6 @@ public class MchPaySettingEntity extends Model<MchPaySettingEntity> {
@Schema(description = "支付json配置")
private String settingJson;
/**
* 平台抽佣比例,单位为百分比,取值范围为大于0且不超过100。
*/
@Schema(description = "平台抽佣比例,单位为百分比,取值范围为(0,100]")
private Double fzRatio;
/**
* 创建人
*/
@@ -108,7 +102,7 @@ public class MchPaySettingEntity extends Model<MchPaySettingEntity> {
static public class WechatPaySetting {
/**
* 子商户号
* 订单创建时从分账关系复制的微信子商户号;不得写回 mch_pay_setting.setting_json。
*/
private String subMchid;
@@ -117,17 +111,17 @@ public class MchPaySettingEntity extends Model<MchPaySettingEntity> {
@Data
static public class AliPaySetting {
/**
* 支付宝直付通二级商户标识,保存支付宝分配的 smid/ip_role_id,不是平台应用商户号。
* 订单创建时从分账关系复制的支付宝 smid/ip_role_id;不得写回支付配置。
*/
private String subMchid;
/**
* 支付宝第三方代调用授权令牌;无需代调用授权的账号允许为空。
* 订单创建时从分账关系复制的第三方代调用授权令牌;无需代调用授权时为空。
*/
private String appAuthToken;
/**
* 平台支付宝命名账号键,来源于现有支付配置 JSON;为空时使用平台默认账号。
* 订单创建时从分账关系复制的平台支付宝命名账号键;为空时使用平台默认账号。
*/
private String accountKey;
}
@@ -14,7 +14,6 @@ import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType;
import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppPlusPayService;
import com.bsd.pjcloud.wallet.api.dto.mch.MchPaySettingDto;
import com.bsd.pjcloud.wallet.api.entity.mch.MchManageEntity;
@@ -60,7 +59,7 @@ public class MchPaySettingServiceImpl extends ServiceImpl<MchPaySettingMapper, M
@Override
public List<MchPaySettingVo> pageList(MchPaySettingDto dto, DataScope dataScope) {
return baseMapper.pageList(dto, dataScope);
return baseMapper.pageList(dto, dataScope).stream().map(this::sanitizeForClient).toList();
}
/**
@@ -79,8 +78,9 @@ public class MchPaySettingServiceImpl extends ServiceImpl<MchPaySettingMapper, M
@Transactional(rollbackFor = Exception.class)
public Long create(MchPaySettingDto dto) {
if (existPlatformType(dto)) {
throw new RuntimeException("改支付类型已经设置,请直接修改");
throw new RuntimeException("该支付类型已经设置,请直接修改");
}
removeRelationOwnedFields(dto);
baseMapper.insert(dto);
syncMerchantRelations(dto.getMchId());
return dto.getId();
@@ -90,9 +90,9 @@ public class MchPaySettingServiceImpl extends ServiceImpl<MchPaySettingMapper, M
@Transactional(rollbackFor = Exception.class)
public MchPaySettingEntity edit(MchPaySettingDto dto) {
if (existPlatformType(dto)) {
throw new RuntimeException("改支付类型已经设置,请直接修改");
throw new RuntimeException("该支付类型已经设置,请直接修改");
}
preserveAliPayAuthToken(dto);
removeRelationOwnedFields(dto);
baseMapper.updateById(dto);
syncMerchantRelations(dto.getMchId());
return sanitizeForClient(dto);
@@ -168,36 +168,33 @@ public class MchPaySettingServiceImpl extends ServiceImpl<MchPaySettingMapper, M
}
/**
* 管理端因安全原因读取不到旧 token,提交空 token 时应保留数据库中的已配置值。
* 删除已经迁入 {@code third_mch_fz_relation} 的渠道字段。
*
* @param dto 管理端提交的支付配置
* <p>滚动发布期间旧管理端仍可能提交这些键,钱包服务必须在持久化边界统一剔除,
* 防止支付配置 JSON 与关系事实形成两个相互漂移的配置源。</p>
*
* @param setting 待持久化或返回客户端的支付配置
*/
private void preserveAliPayAuthToken(MchPaySettingDto dto) {
if (dto == null || dto.getId() == null || !AppPlusPlatformPayType.ALI_PAY.getType().equals(dto.getPlatformType())
|| StrUtil.isBlank(dto.getSettingJson())) {
private void removeRelationOwnedFields(MchPaySettingEntity setting) {
if (setting == null || StrUtil.isBlank(setting.getSettingJson())) {
return;
}
MchPaySettingEntity existing = getById(dto.getId());
if (existing == null || StrUtil.isBlank(existing.getSettingJson())) {
return;
if (!JSONUtil.isTypeJSON(setting.getSettingJson())) {
throw new RuntimeException("支付配置JSON无效");
}
try {
MchPaySettingEntity.AliPaySetting incoming = JSONUtil.toBean(
dto.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
MchPaySettingEntity.AliPaySetting stored = JSONUtil.toBean(
existing.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
if (incoming != null && stored != null && StrUtil.isBlank(incoming.getAppAuthToken())
&& StrUtil.isNotBlank(stored.getAppAuthToken())) {
incoming.setAppAuthToken(stored.getAppAuthToken());
dto.setSettingJson(JSONUtil.toJsonStr(incoming));
}
var settingJson = JSONUtil.parseObj(setting.getSettingJson());
settingJson.remove("subMchid");
settingJson.remove("accountKey");
settingJson.remove("appAuthToken");
setting.setSettingJson(settingJson.isEmpty() ? null : settingJson.toString());
} catch (RuntimeException exception) {
throw new RuntimeException("支付宝支付配置无效");
throw new RuntimeException("支付配置JSON无效");
}
}
/**
* 复制并脱敏支付配置,保证调用方不能通过返回对象修改缓存或内部商户配置。
* 复制支付配置并剔除关系表负责的渠道字段,保证客户端不会读到旧配置或完整授权令牌。
*
* @param source 内部完整支付配置
* @param <T> 支付配置实体或视图类型
@@ -209,18 +206,10 @@ public class MchPaySettingServiceImpl extends ServiceImpl<MchPaySettingMapper, M
return null;
}
T target = (T) BeanUtil.toBean(source, source.getClass());
if (!AppPlusPlatformPayType.ALI_PAY.getType().equals(target.getPlatformType()) || StrUtil.isBlank(target.getSettingJson())) {
return target;
}
try {
MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(
target.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
if (aliPaySetting != null) {
aliPaySetting.setAppAuthToken(null);
target.setSettingJson(JSONUtil.toJsonStr(aliPaySetting));
}
removeRelationOwnedFields(target);
} catch (RuntimeException exception) {
// 无法解析的历史配置不应原样下发,以免其中包含未识别的敏感字段。
// 无法解析的历史配置不原样下发,避免其中残留完整授权令牌。
target.setSettingJson(null);
}
return target;
@@ -11,7 +11,6 @@
a.enable,
a.use_scope,
a.setting_json,
a.fz_ratio,
a.create_by,
a.create_time,
a.update_by,
@@ -50,4 +49,4 @@
<include refid="scopePage"/>
</select>
</mapper>
</mapper>
@@ -23,7 +23,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 商户支付配置支付宝授权令牌脱敏和更新保留测试。
* 商户支付配置关系字段清理测试。
*/
@ExtendWith(MockitoExtension.class)
class MchPaySettingServiceImplTest {
@@ -57,31 +57,26 @@ class MchPaySettingServiceImplTest {
}
/**
* 验证管理端详情保留 smid 和账号键,但不返回完整授权令牌。
* 验证管理端详情不再返回已经迁入关系表的任何渠道字段。
*/
@Test
void getSafeByIdShouldRemoveAliPayAuthToken() {
void getSafeByIdShouldRemoveRelationOwnedFields() {
MchPaySettingEntity stored = storedAliPaySetting();
when(mchPaySettingMapper.selectById(stored.getId())).thenReturn(stored);
MchPaySettingEntity result = service.getSafeById(stored.getId());
MchPaySettingEntity.AliPaySetting safe = JSONUtil.toBean(
result.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
Assertions.assertEquals("smid-1001", safe.getSubMchid());
Assertions.assertEquals("secondary", safe.getAccountKey());
Assertions.assertNull(safe.getAppAuthToken());
Assertions.assertNull(result.getSettingJson());
Assertions.assertTrue(stored.getSettingJson().contains("sensitive-auth-token"));
}
/**
* 验证前端提交空 token 时保存旧值,但接口返回值仍保持脱敏。
* 验证旧管理端继续提交关系字段时,钱包持久化边界仍会全部剔除。
*/
@Test
void editShouldPreserveStoredAliPayAuthTokenWithoutReturningIt() {
void editShouldDiscardLegacyRelationOwnedFields() {
MchPaySettingEntity stored = storedAliPaySetting();
when(mchPaySettingMapper.selectCount(any())).thenReturn(1L);
when(mchPaySettingMapper.selectById(stored.getId())).thenReturn(stored);
when(mchManageService.join(stored.getMchId())).thenReturn(new com.bsd.pjcloud.wallet.api.vo.MchManageVo());
when(thirdAppPlusPayService.remoterChangeMch(any())).thenReturn(R.ok(true));
MchPaySettingEntity.AliPaySetting incomingSetting = new MchPaySettingEntity.AliPaySetting();
@@ -98,12 +93,8 @@ class MchPaySettingServiceImplTest {
ArgumentCaptor<MchPaySettingEntity> updateCaptor = ArgumentCaptor.forClass(MchPaySettingEntity.class);
verify(mchPaySettingMapper).updateById(updateCaptor.capture());
MchPaySettingEntity.AliPaySetting persisted = JSONUtil.toBean(
updateCaptor.getValue().getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
Assertions.assertEquals("sensitive-auth-token", persisted.getAppAuthToken());
MchPaySettingEntity.AliPaySetting safeResult = JSONUtil.toBean(
result.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
Assertions.assertNull(safeResult.getAppAuthToken());
Assertions.assertNull(updateCaptor.getValue().getSettingJson());
Assertions.assertNull(result.getSettingJson());
}
/**