diff --git a/db/20260805_add_third_aggregate_pay.sql b/db/20260805_add_third_aggregate_pay.sql deleted file mode 100644 index 734e27f86..000000000 --- a/db/20260805_add_third_aggregate_pay.sql +++ /dev/null @@ -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 = '支付宝与微信聚合订单码支付会话'; diff --git a/db/20260824_add_third_payment_task.sql b/db/20260824_add_third_payment_task.sql deleted file mode 100644 index 314682346..000000000 --- a/db/20260824_add_third_payment_task.sql +++ /dev/null @@ -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。 diff --git a/db/20260825_add_third_mch_fz_relation.sql b/db/20260825_add_third_mch_fz_relation.sql deleted file mode 100644 index 6ffc39cc4..000000000 --- a/db/20260825_add_third_mch_fz_relation.sql +++ /dev/null @@ -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; diff --git a/db/20260825_third_payment_schema.sql b/db/20260825_third_payment_schema.sql new file mode 100644 index 000000000..9841ed270 --- /dev/null +++ b/db/20260825_third_payment_schema.sql @@ -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; diff --git a/db/800pjcloudx_wallet.sql b/db/800pjcloudx_wallet.sql index 462e9448b..deffa17b6 100644 --- a/db/800pjcloudx_wallet.sql +++ b/db/800pjcloudx_wallet.sql @@ -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 '修改人', diff --git a/pjcloud-common/pjcloud-common-data/src/main/java/com/bsd/pjcloud/common/data/datascope/DataScopeInnerInterceptor.java b/pjcloud-common/pjcloud-common-data/src/main/java/com/bsd/pjcloud/common/data/datascope/DataScopeInnerInterceptor.java index 293b6a47d..913f1931a 100644 --- a/pjcloud-common/pjcloud-common-data/src/main/java/com/bsd/pjcloud/common/data/datascope/DataScopeInnerInterceptor.java +++ b/pjcloud-common/pjcloud-common-data/src/main/java/com/bsd/pjcloud/common/data/datascope/DataScopeInnerInterceptor.java @@ -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 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()); } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java index b28ea5302..6633dceeb 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java @@ -26,7 +26,12 @@ public enum HardAutoRefundTriggerTypeEnum { /** * 管理端业务人员对已到期候选订单确认后执行的逐单退款,不复用技术重试权限。 */ - EXPIRED_MANUAL("40", "后台逐单过期退款", false); + EXPIRED_MANUAL("40", "后台逐单过期退款", false), + + /** + * 管理端按商户当前配置立即重扫历史订单后触发的自动退款,失败后继续进入既有自动重试状态机。 + */ + EXPIRED_RESCAN("50", "后台立即重新扫描", true); /** * 数据库存储的稳定触发编码。 diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java index d124723d4..1665a7198 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java @@ -163,11 +163,11 @@ public class HardOrderEntity extends Model { @Schema(description = "过期预约退款配置ID快照") private Long expiredRefundConfigId; - /** 预约订单支付成功时解析出的实际餐段结束时间;为空表示无法安全确定过期资格。 */ - @Schema(description = "过期预约餐段实际结束时间快照") + /** 预约下单校验通过时固化的用餐日核销有效期结束时间,固定为 apptUseDate 当天 23:59:59;不受退款开关影响。 */ + @Schema(description = "过期预约用餐日核销结束时间快照") private LocalDateTime expiredRefundUsageEndTime; - /** 实际餐段结束时间加配置延迟后的过期退款触发时间。 */ + /** 用餐日核销有效期结束时间加配置延迟后的过期退款触发时间。 */ @Schema(description = "过期预约退款触发时间") private LocalDateTime expiredRefundDeadline; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardExpiredRefundRescanResultVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardExpiredRefundRescanResultVo.java new file mode 100644 index 000000000..dcfc0c56a --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardExpiredRefundRescanResultVo.java @@ -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; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java index faecc1da8..fa5149d9f 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java @@ -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> expiredCandidates(@ParameterObject Page page, - @ParameterObject HardAutoRefundTaskPageDto dto) { - List scopedMchIds = listScopedMerchantIds(); - if (scopedMchIds.isEmpty()) { - Page empty = new Page<>(page.getCurrent(), page.getSize(), 0L); - empty.setRecords(List.of()); - return R.ok(empty); - } - dto.setMchIds(scopedMchIds); + public R> expiredCandidates(@ParameterObject Page 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 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> 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;远程异常拒绝请求,不能降级为全量任务。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java index 22ed964e1..ea59db718 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java @@ -115,6 +115,67 @@ public class HardCanteenTotalController { return R.ok(canteenTotalService.proudctMchQuery(page, dto)); } + /** + * 按销售发生时间分页查询 App 食堂综合销售统计。 + * + *

线下购买按支付时间统计,预约订单按核销时间统计;返回结构与 PC 菜品销售统计一致, + * 并且只允许查询当前商户管理员可见的食堂。

+ * + * @param page 按自然日分页的分页参数 + * @param dto 统计日期、来源和餐段筛选条件 + * @return 日期、餐段和来源多维销售统计分页 + */ + @ApiCryptoAes + @Operation(summary = "App食堂综合销售统计", description = "线下按支付时间、预约按核销时间查询日期餐段来源统计") + @PostMapping("/app/proudct-total") + public R> appProductTotal(@ParameterObject Page page, + @RequestBody HardOrderDto dto) { + if (!applyAppCanteenMerchantScope(dto)) { + return R.ok(new Page()); + } + 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> 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 allowedMchIds = remoteMchService + .getSelfMchIdList(user.getId(), List.of(MerchantTypeEnum.CANTEEN)).getData(); + if (ObjectUtil.isEmpty(allowedMchIds)) { + dto.setMchIds(List.of()); + return false; + } + List requestedMchIds = dto.getMchIds(); + List 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") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java index b9936a980..c770ec0a8 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java @@ -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 mchList = mchService.scopeList(mchManageDto).getData(); - if (ObjectUtil.isEmpty(mchList)) { + List mchIdList = mchService.getSelfMchIdList(SecurityUtils.getUser().getId(), List.of(MerchantTypeEnum.CANTEEN)).getData(); + if (ObjectUtil.isEmpty(mchIdList)) { return R.ok(new Page()); } - List 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()); } - 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 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 mchDtoIds = dto.getMchIds(); - if (ObjectUtil.isNotEmpty(mchDtoIds) && mchDtoIds.size() == 1) { - Long mchId = mchDtoIds.get(0); - List mchList = mchService.getByParentId(mchId).getData(); - if (ObjectUtil.isNotEmpty(mchList)) { - List mchIds = mchList.stream().map(MchManageVo::getId).toList(); - mchDtoIds.addAll(mchIds); - dto.setMchIds(mchDtoIds); - } - } - List list = hardwareOrderService.scopeList(dto, DataScope.of()); - List 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 map = new HashMap(); - 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); } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java index 911c3d714..56e69579a 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java @@ -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 pageExpiredCandidates(Page page, @Param("dto") HardAutoRefundTaskPageDto dto, @Param("now") LocalDateTime now); + + /** + * 查询指定商户仍处于已支付未核销状态且没有冲突售后的每日菜谱钱包订单,供立即重扫补建任务。 + * + * @param mchId 精确商户 ID + * @return 按订单主键升序排列的可重扫订单 + */ + List listExpiredRescanOrders(@Param("mchId") Long mchId); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java index fa8a34eef..2f96b3b99 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java @@ -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) { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java index 212fb7495..3c5695af3 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java @@ -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 pageExpiredCandidates(Page page, HardAutoRefundTaskPageDto dto); + /** + * 按商户当前过期预约配置重扫历史已支付未核销订单,补建或刷新任务并立即执行已到期任务。 + * + * @param mchId 需要重扫的商户 ID + * @param operatorUserId 发起重扫的后台用户 ID,用于尝试日志审计 + * @return 本轮扫描、任务同步和退款执行数量 + */ + HardExpiredRefundRescanResultVo rescanExpiredReserveOrders(Long mchId, Long operatorUserId); + /** * 查询任务的只读尝试日志,按尝试序号升序返回。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderExportService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderExportService.java new file mode 100644 index 000000000..037c8de27 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderExportService.java @@ -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; + +/** + * 硬件订单导出服务。 + * + *

该服务独立承担导出条件冻结、数据权限、分批查询、模板写入、进度上报和文件中心上传, + * 订单查询服务只保留订单业务查询职责。

+ */ +public interface HardOrderExportService { + + /** + * 提交硬件订单异步分批导出任务。 + * + * @param dto 订单页面当前筛选条件;orderIds 非空时仅导出勾选订单 + * @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址 + */ + AsyncTaskProgressVO submitExport(HardOrderDto dto); + + /** + * 兼容旧页面的同步导出入口,同样按页查询并通过临时文件流式生成工作簿。 + * + * @param response 当前 HTTP 下载响应,方法会写入完整 XLSX 文件 + * @param dto 订单页面当前筛选条件 + */ + void export(HttpServletResponse response, HardOrderDto dto); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java index 43a8c4ba3..2dddc62b1 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java @@ -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 candidates = hardApptService.list(new LambdaQueryWrapper() - .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, diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java index 7b7fff232..bb4b0c7cf 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java @@ -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 pageExpiredCandidates(Page page, - HardAutoRefundTaskPageDto dto) { + public Page pageExpiredCandidates(Page page, HardAutoRefundTaskPageDto dto) { LocalDateTime now = LocalDateTime.now(); Page entityPage = baseMapper.pageExpiredCandidates(page, dto, now); List records = BeanUtils.toBean(entityPage.getRecords(), HardAutoRefundTaskVo.class); @@ -981,7 +1003,193 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl 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 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() + .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() + .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() + .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 records) { List orderIds = records.stream().map(HardAutoRefundTaskVo::getOrderId).filter(Objects::nonNull).distinct().toList(); if (orderIds.isEmpty()) return; @@ -1092,7 +1300,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl 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 listAllFilteredRows(HardCanteenUserApptQueryDto query, - List queryDates) { + private List listAllFilteredRows(HardCanteenUserApptQueryDto query, List queryDates) { List users = fetchAllUsers(query); return filterRows(buildRows(users, query, queryDates), query); } @@ -536,8 +535,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ * @return 含准确总数的用户分页 */ private Page fetchUserPage(long current, long size, HardCanteenUserApptQueryDto query) { - R> response = remoteUserService.getScopeBasicUserPage(current, size, - buildUserQuery(query)); + R> 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); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java index 43bc32871..aa750f300 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java @@ -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 { *

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

* - * @param page 分页参数;新版统计的每页数量表示自然日数量 - * @param dto 统计日期、来源、食堂、餐段和部门筛选 + * @param page 分页参数;新版统计的每页数量表示自然日数量 + * @param dto 统计日期、来源、食堂、餐段和部门筛选 * @param dataScope 订单部门数据范围;为空时不追加数据权限条件 * @return 按自然日分页且包含餐段和来源明细的统计结果 */ @@ -321,16 +370,16 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { *

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

* - * @param requestedPage 调用方请求的页码和每页自然日数量 - * @param rows 数据库返回的当日合计、当日来源、餐段合计和餐段来源行 - * @param hourTypeFiltered 是否已经按单一餐段筛选 + * @param requestedPage 调用方请求的页码和每页自然日数量 + * @param rows 数据库返回的当日合计、当日来源、餐段合计和餐段来源行 + * @param hourTypeFiltered 是否已经按单一餐段筛选 * @param includeSourceRows 是否挂载来源拆分行;仅“全部”来源视图需要展示,单来源视图禁用以避免重复行 * @return 仅包含当前页自然日的合计行,每行附带来源对比数据 */ private Page assembleProductStatisticsPage(Page requestedPage, - List rows, - boolean hourTypeFiltered, - boolean includeSourceRows) { + List rows, + boolean hourTypeFiltered, + boolean includeSourceRows) { Page result = new Page<>(requestedPage.getCurrent(), requestedPage.getSize()); if (ObjectUtil.isEmpty(rows)) { result.setTotal(0); @@ -433,8 +482,8 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { * “部门 + 餐段”的跨来源合计为主行并挂载来源拆分。所有合计均来自数据库独立聚合,避免通过 * 来源行相加导致交易人数重复。未传统计日期范围时继续使用旧查询结构。

* - * @param dto 统计日期、来源、食堂、餐段和部门筛选;totalIsFindChildren 决定是否查询展开明细 - * @param user 当前登录用户,保留给既有商户范围契约 + * @param dto 统计日期、来源、食堂、餐段和部门筛选;totalIsFindChildren 决定是否查询展开明细 + * @param user 当前登录用户,保留给既有商户范围契约 * @param dataScope 订单所属部门数据范围;为空时不附加范围条件 * @return 食堂部门统计主行;新版主行附带来源拆分和部门餐段明细 */ @@ -442,12 +491,10 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { public List 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 mchList = remoteMchService.scopeList(mchListDto).getData(); - if (ObjectUtil.isEmpty(mchList)) return null; - dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList()); + List 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 mapperRows = sourceStatistics @@ -494,15 +541,35 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { return list; } + /** + * 将部门统计携带的身份、人脸及扩展资料条件转换为订单用户主键范围。 + * + *

订单表不持久化学生、老师和人脸资料,必须先由 UPMS 在当前用户数据权限内筛选用户; + * 无匹配结果时支持器会返回不可命中的用户主键,避免 Mapper 将空集合误解为不限制用户。

+ * + * @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 assembleDepartmentMainRows(List rows, - boolean includeSourceRows) { + boolean includeSourceRows) { if (ObjectUtil.isEmpty(rows)) return List.of(); List 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 assembleDepartmentChildRows(List rows, - Long merchantId, - boolean includeSourceRows) { + Long merchantId, + boolean includeSourceRows) { if (ObjectUtil.isEmpty(rows)) return List.of(); rows.forEach(row -> row.setMchId(merchantId)); List sourceRows = rows.stream() @@ -558,18 +625,18 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { *

旧实现按每个“日期 + 食堂”主行执行一次 SQL,日期或食堂增多时会形成 N+1;新版 SQL 已返回 * 食堂主键,因此可在相同筛选范围内一次聚合后按稳定键拆分,同时保持来源行和合计行的独立去重口径。

* - * @param dto 已补全可见食堂、来源和统计日期的查询条件 - * @param mainRows 日期食堂合计主行,每行可能挂载来源拆分 + * @param dto 已补全可见食堂、来源和统计日期的查询条件 + * @param mainRows 日期食堂合计主行,每行可能挂载来源拆分 * @param includeSourceRows 是否为合计行挂载来源拆分;单来源标签页为 false */ private void populateDepartmentStatisticsChildren(HardOrderDto dto, - List mainRows, - boolean includeSourceRows) { + List mainRows, + boolean includeSourceRows) { List allChildRows = hardCarMapper.listByHourTypeStats(dto); Map> 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 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 departmentSourceChildren(List 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 { *

每个日期餐段先输出当前来源筛选的跨来源合计块,再输出各已选来源块;合计人数由数据库独立去重, * 不能通过来源人数相加得到。两个统计页面调用同一实现,因此筛选、退款扣减、套餐展开和合计规则保持一致。

* - * @param dto 统计日期、来源、餐段、食堂和部门筛选条件 + * @param dto 统计日期、来源、餐段、食堂和部门筛选条件 * @param dataScope 数据权限上下文,继续透传给 Mapper 拦截器 * @return 按日期餐段、来源口径、商户部门和商品顺序排列的 Excel 行 */ @@ -722,15 +789,15 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { * *

权限范围在请求线程完成收敛,后台线程只读取独立 DTO,避免调用方继续修改数组或列表导致导出条件漂移。

* - * @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 { *

每个自然日完成 SQL 聚合后立即写盘并释放当日行,日期跨度增大时 JVM 不会同时持有全部导出结果。 * 进度总数由日期批次数、工作簿收尾和文件上传三部分组成。

* - * @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 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 { * 之和严格等于订单快照金额。找不到套餐菜谱时保留原商品行。

* * @param product 商品聚合记录 - * @param menu 与商户、日期、餐段和商品匹配的每日菜谱 + * @param menu 与商户、日期、餐段和商品匹配的每日菜谱 * @return 原商品行或套餐组成商品行 */ private List 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 餐段名称或原编码 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java new file mode 100644 index 000000000..a0da9b174 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java @@ -0,0 +1,1060 @@ +package com.bsd.pjcloud.hard.service.impl.order; + +import cn.hutool.core.date.DatePattern; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.io.resource.ResourceUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.constant.user.UserIdentityTypeEnum; +import com.bsd.pjcloud.admin.api.dto.UserDTO; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; +import com.bsd.pjcloud.admin.api.entity.SysPost; +import com.bsd.pjcloud.admin.api.feign.RemoteFileService; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.admin.api.vo.UserVO; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; +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.excel.stream.BatchExcelExportTemplate; +import com.bsd.pjcloud.common.security.util.SecurityUtils; +import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; +import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; +import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; +import com.bsd.pjcloud.hard.service.HardManagerService; +import com.bsd.pjcloud.hard.service.HardOrderExportService; +import com.bsd.pjcloud.hard.support.HardUserIdentitySupport; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.xssf.streaming.SXSSFSheet; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.beans.BeanUtils; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * 硬件订单导出服务实现。 + * + *

导出前冻结当前用户可见的设备类型、商户范围和身份筛选,后台任务按稳定顺序分页读取订单, + * 并在同一任务线程同步补充完整用户资料和商户名称。使用 SXSSF 保留原订单模板样式, + * 完成后上传文件中心并返回下载地址。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HardOrderExportServiceImpl implements HardOrderExportService { + + /** 订单导出日志标签,用于定位分批查询、模板写入和文件上传异常。 */ + private static final String LOG_TAG = "硬件订单导出"; + + /** 订单导出任务的 Redis Key 业务命名空间。 */ + private static final String ORDER_EXPORT_REDIS_KEY = "hard:order:export"; + + /** 订单导出任务在进度弹窗中的展示名称。 */ + private static final String ORDER_EXPORT_TASK_NAME = "订单记录导出"; + + /** 后台取得真实订单数前创建任务使用的占位总数。 */ + private static final int ORDER_EXPORT_INITIAL_TOTAL = 1; + + /** 单次订单查询和 SXSSF 内存窗口使用的记录数。 */ + private static final int ORDER_EXPORT_BATCH_SIZE = 200; + + /** 单次查询完整用户导出资料的最大数量,当前订单分片不会超过该值。 */ + private static final long USER_EXPORT_BATCH_SIZE = 500L; + + /** 文件上传及异步任务成功收尾占用的额外进度步数。 */ + private static final int ORDER_EXPORT_EXTRA_PROGRESS_STEPS = 2; + + /** 订单模板标题行的 Excel 零基行号。 */ + private static final int ORDER_TITLE_ROW_INDEX = 0; + + /** 订单模板表头行的 Excel 零基行号。 */ + private static final int ORDER_HEADER_ROW_INDEX = 1; + + /** 订单模板中明细占位行的 Excel 零基行号。 */ + private static final int ORDER_DETAIL_TEMPLATE_ROW_INDEX = 2; + + /** 订单模板中合计占位行的 Excel 零基行号。 */ + private static final int ORDER_SUMMARY_TEMPLATE_ROW_INDEX = 3; + + /** 订单明细从模板第三行开始写入。 */ + private static final int ORDER_DETAIL_START_ROW_INDEX = 2; + + /** 订单明细之后为合计保留一行。 */ + private static final int ORDER_RESERVED_TAIL_ROW_COUNT = 1; + + /** 既有订单模板在运行时扩展前固定包含的列数。 */ + private static final int ORDER_ORIGINAL_TEMPLATE_COLUMN_COUNT = 12; + + /** + * 订单导出的完整列标题,用户资料沿用用户预约导出的基础信息和身份字段口径。 + */ + private static final String[] ORDER_EXPORT_HEADERS = { + "行号", "商户", "用户编号", "姓名", "邮箱", "部门", "岗位", "手机号", "身份类型", + "学号", "学校名称", "班级名称", "监护人手机号", "老师学号", "商品", "支付方式", + "用餐时间段", "商品总价", "额外支付", "实际支付", "订单支付时间" + }; + + /** + * 各导出列复用的原模板明细样式来源列,保留文本、金额和日期列的既有显示格式。 + */ + private static final int[] ORDER_DETAIL_STYLE_SOURCE_COLUMNS = { + 0, 1, 0, 2, 3, 4, 4, 3, 4, 4, 4, 4, 4, 4, 5, 6, 7, 8, 9, 10, 11 + }; + + /** + * 各导出列复用的原模板合计样式来源列,合并区末列单独保留右边框。 + */ + private static final int[] ORDER_SUMMARY_STYLE_SOURCE_COLUMNS = { + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 6, 8, 9, 10, 11 + }; + + /** 各导出列宽度,单位为 Excel 字符宽度。 */ + private static final int[] ORDER_EXPORT_COLUMN_WIDTHS = { + 10, 20, 20, 16, 28, 20, 20, 18, 18, 18, 22, 18, 24, 18, 30, 18, 18, 16, 16, 16, 22 + }; + + /** 订单模板扩展后的固定列数。 */ + private static final int ORDER_EXPORT_COLUMN_COUNT = ORDER_EXPORT_HEADERS.length; + + /** 商品总价在扩展模板中的 Excel 零基列号。 */ + private static final int ORDER_TOTAL_PRICE_COLUMN_INDEX = 17; + + /** 额外支付在扩展模板中的 Excel 零基列号。 */ + private static final int ORDER_EXTRA_PRICE_COLUMN_INDEX = 18; + + /** 实际支付在扩展模板中的 Excel 零基列号。 */ + private static final int ORDER_PAY_PRICE_COLUMN_INDEX = 19; + + /** 合计文本合并区域的最后一个 Excel 零基列号。 */ + private static final int ORDER_SUMMARY_MERGE_LAST_COLUMN_INDEX = 16; + + /** 单个订单工作表最多允许写入的明细数量。 */ + private static final int MAX_ORDER_EXPORT_ROWS = BatchExcelExportTemplate.calculateMaxDetailRowCount( + ORDER_DETAIL_START_ROW_INDEX, ORDER_RESERVED_TAIL_ROW_COUNT); + + /** 订单导出使用的既有类路径模板。 */ + private static final String ORDER_EXPORT_TEMPLATE_PATH = "file/订单交易汇总.xlsx"; + + /** 文件中心保存订单导出文件的业务目录。 */ + private static final String ORDER_EXPORT_FILE_DIR = "hard-export"; + + /** 文件中心区分订单导出文件的业务类型编码。 */ + private static final String ORDER_EXPORT_FILE_TYPE = "HARD_ORDER_EXPORT"; + + /** XLSX 文件的标准 MIME 类型。 */ + private static final String ORDER_EXPORT_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + + /** 异步订单导出文件名使用的时间格式。 */ + private static final DateTimeFormatter ORDER_EXPORT_FILE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + /** 订单 Mapper,负责在当前导出任务线程内按数据权限分页读取原始订单。 */ + private final HardOrderMapper hardOrderMapper; + + /** 用户身份支持器,负责把身份筛选条件预转换为用户主键范围。 */ + private final HardUserIdentitySupport userIdentitySupport; + + /** 用户远程服务,负责在当前导出任务线程内批量查询完整基础资料和身份资料。 */ + private final RemoteUserService remoteUserService; + + /** 设备服务,用于冻结当前用户允许查看的设备类型。 */ + private final HardManagerService hardManagerService; + + /** 商户远程服务,用于把单个商户筛选扩展为其现有子商户范围。 */ + private final RemoteMchService remoteMchService; + + /** 文件中心远程服务,用于持久化异步生成的订单工作簿。 */ + private final RemoteFileService remoteFileService; + + /** 通用异步任务执行器,负责恢复租户、安全和 Feign Token 上下文并持久化进度。 */ + private final AsyncTaskExecutorService asyncTaskExecutorService; + + /** + * 提交订单异步分批导出任务。 + * + * @param dto 订单页面当前筛选条件;orderIds 非空时仅导出勾选订单 + * @return 可供前端轮询的初始任务进度 + */ + @Override + public AsyncTaskProgressVO submitExport(HardOrderDto dto) { + HardOrderDto querySnapshot = prepareExportQuery(copyQuery(dto)); + return asyncTaskExecutorService.submitWithResult(ORDER_EXPORT_REDIS_KEY, ORDER_EXPORT_TASK_NAME, + ORDER_EXPORT_INITIAL_TOTAL, reporter -> exportToFile(querySnapshot, reporter)); + } + + /** + * 执行兼容旧页面的同步分批导出。 + * + *

旧接口仍保持原 URL 和响应方式,但内部不再一次性读取全部订单;完整临时文件生成后才写入响应, + * 避免部分失败文件被浏览器当作成功结果下载。

+ * + * @param response 当前 HTTP 下载响应 + * @param dto 订单页面当前筛选条件 + */ + @Override + public void export(HttpServletResponse response, HardOrderDto dto) { + HardOrderDto querySnapshot = prepareExportQuery(copyQuery(dto)); + Page firstPage = fetchExportPage(1L, ORDER_EXPORT_BATCH_SIZE, querySnapshot, true); + long total = validateExportTotal(firstPage.getTotal()); + response.setContentType(ORDER_EXPORT_CONTENT_TYPE); + writeExportWorkbook(querySnapshot, firstPage, total, null, file -> { + Files.copy(file, response.getOutputStream()); + response.flushBuffer(); + return null; + }); + } + + /** + * 在异步线程中生成订单工作簿并上传文件中心。 + * + * @param query 已冻结权限和筛选条件的订单查询 + * @param reporter 当前异步任务进度报告器 + * @return 文件中心下载地址和业务文件名 + */ + private AsyncTaskResult exportToFile(HardOrderDto query, AsyncTaskProgressReporter reporter) { + Page firstPage = fetchExportPage(1L, ORDER_EXPORT_BATCH_SIZE, query, true); + long total = validateExportTotal(firstPage.getTotal()); + int progressTotal = BatchExcelExportTemplate.calculateProgressTotal(total, ORDER_EXPORT_BATCH_SIZE, + ORDER_EXPORT_EXTRA_PROGRESS_STEPS); + reporter.updateTotal(progressTotal, "已查询 " + total + " 条订单,将分批生成文件"); + + String fileName = "订单记录_" + LocalDateTime.now().format(ORDER_EXPORT_FILE_TIME_FORMATTER) + ".xlsx"; + try { + String fileUrl = writeExportWorkbook(query, firstPage, total, reporter, + file -> uploadExportFile(file, fileName)); + reporter.advance("订单导出文件已上传,共 " + total + " 条"); + return AsyncTaskResult.file(fileUrl, fileName); + } catch (IllegalArgumentException | IllegalStateException exception) { + throw exception; + } catch (Exception exception) { + log.error("|{}|生成或上传文件失败,fileName:{}", LOG_TAG, fileName, exception); + throw new IllegalStateException("生成订单导出文件失败", exception); + } + } + + /** + * 使用公共模板按页查询订单、流式写入明细和合计,并消费最终 XLSX 临时文件。 + * + * @param query 已冻结的订单查询条件 + * @param firstPage 已用于取得总数的第一页,避免重复查询首批订单 + * @param total 导出开始时按相同条件统计的订单总数 + * @param reporter 异步任务进度报告器;同步兼容导出时为空 + * @param fileConsumer 完整临时文件消费回调 + * @param 文件消费结果类型 + * @return 文件消费回调返回的结果 + */ + private R writeExportWorkbook(HardOrderDto query, Page firstPage, long total, + AsyncTaskProgressReporter reporter, + BatchExcelExportTemplate.FileConsumer fileConsumer) { + try { + return BatchExcelExportTemplate.builder(total) + .batchSize(ORDER_EXPORT_BATCH_SIZE) + .dataStartRowIndex(ORDER_DETAIL_START_ROW_INDEX) + .reservedTailRowCount(ORDER_RESERVED_TAIL_ROW_COUNT) + .temporaryFilePrefix("hard-order-export-") + .templateSupplier(() -> ResourceUtil.getStream(ORDER_EXPORT_TEMPLATE_PATH)) + .templateInitializer(workbook -> prepareTemplate(workbook, query)) + .pageFetcher((pageNumber, batchSize) -> pageNumber == 1L + ? safeRecords(firstPage) + : safeRecords(fetchExportPage(pageNumber, batchSize, query, false))) + .rowWriter((sheet, rowIndex, dataIndex, item, context) -> { + context.summary.add(item); + writeDetailRow(sheet, rowIndex, dataIndex, item, context); + }) + .footerWriter((sheet, rowIndex, processedTotal, context) -> + writeSummaryRow(sheet, rowIndex, context)) + .progressListener((processedTotal, expectedTotal) -> { + if (reporter != null) { + reporter.advance("已写入 " + processedTotal + " / " + expectedTotal + " 条订单"); + } + }) + .fileConsumer(fileConsumer) + .execute(); + } catch (IllegalArgumentException | IllegalStateException exception) { + throw exception; + } catch (Exception exception) { + throw new IllegalStateException("生成订单导出文件失败", exception); + } + } + + /** + * 查询指定订单导出分页,数据范围和列表页保持为商户部门权限。 + * + * @param current 从一开始的页码 + * @param size 单批最大订单数 + * @param query 已冻结的订单筛选条件 + * @param searchCount 是否执行总数查询,仅第一页需要 + * @return 在当前线程同步装配完整用户资料和商户名称的导出分页 + */ + private Page fetchExportPage(long current, long size, HardOrderDto query, boolean searchCount) { + Page page = new Page<>(current, size, searchCount); + Page orderPage = hardOrderMapper.scopePageList(page, query, createOrderDataScope()); + Page exportPage = new Page<>(current, size, orderPage.getTotal(), searchCount); + exportPage.setRecords(buildExportRows(orderPage.getRecords())); + return exportPage; + } + + /** + * 在导出任务线程中同步装配一个订单分片,避免列表服务的公共线程池丢失 Feign 登录凭证。 + * + * @param orders 当前订单分片,最多为订单导出批大小 + * @return 保持订单原始顺序并携带完整用户资料、商户名称的导出行 + */ + private List buildExportRows(List orders) { + if (orders == null || orders.isEmpty()) { + return List.of(); + } + List userIds = orders.stream().map(HardOrderVo::getUserId).filter(Objects::nonNull).distinct().toList(); + List merchantIds = orders.stream().map(HardOrderVo::getMerchantId).filter(Objects::nonNull).distinct().toList(); + Map userMap = fetchExportUserMap(userIds); + Map merchantNameMap = fetchMerchantNameMap(merchantIds); + return orders.stream().map(order -> new OrderExportRow(order, userMap.get(order.getUserId()), + StrUtil.blankToDefault(merchantNameMap.get(order.getMerchantId()), order.getMerchantName()))) + .toList(); + } + + /** + * 按当前订单分片的用户主键查询完整用户导出资料。 + * + * @param userIds 当前订单分片去重后的 UPMS 用户主键 + * @return 以用户主键索引的完整用户资料;订单未关联用户时返回空映射 + */ + private Map fetchExportUserMap(List userIds) { + if (userIds.isEmpty()) { + return Map.of(); + } + UserDTO userQuery = new UserDTO(); + userQuery.setUserIds(userIds); + R> response = remoteUserService.getScopeExportUserPage(1L, USER_EXPORT_BATCH_SIZE, userQuery); + if (response == null || !response.isOk() || response.getData() == null) { + String message = response == null ? null : response.getMsg(); + throw new IllegalStateException(StrUtil.blankToDefault(message, "查询订单导出用户资料失败")); + } + List users = response.getData().getRecords(); + if (users == null || users.isEmpty()) { + return Map.of(); + } + return users.stream().filter(user -> user != null && user.getUserId() != null) + .collect(Collectors.toMap(UserVO::getUserId, user -> user, (left, right) -> left, + LinkedHashMap::new)); + } + + /** + * 批量查询订单商户名称并校验钱包服务业务响应。 + * + * @param merchantIds 当前订单分片去重后的商户主键 + * @return 商户主键到名称的映射;订单未关联系统商户时返回空映射 + */ + private Map fetchMerchantNameMap(List merchantIds) { + if (merchantIds.isEmpty()) { + return Map.of(); + } + R> response = remoteMchService.getByIds(merchantIds); + if (response == null || !response.isOk()) { + String message = response == null ? null : response.getMsg(); + throw new IllegalStateException(StrUtil.blankToDefault(message, "查询订单导出商户资料失败")); + } + if (response.getData() == null || response.getData().isEmpty()) { + return Map.of(); + } + return response.getData().stream().filter(merchant -> merchant != null && merchant.getId() != null + && StrUtil.isNotBlank(merchant.getName())) + .collect(Collectors.toMap(MchManageVo::getId, MchManageVo::getName, (left, right) -> left, + LinkedHashMap::new)); + } + + /** + * 创建订单页面使用的数据权限配置,以商户部门字段限制当前用户可见订单。 + * + * @return 独立的数据权限参数,供单次分页查询使用 + */ + private DataScope createOrderDataScope() { + DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL); + dataScope.setScopeDeptName("merchant_dept_id"); + return dataScope; + } + + /** + * 校验导出总数和单工作表容量。 + * + * @param total 第一页分页查询返回的订单总数 + * @return 已通过校验的订单总数 + */ + private long validateExportTotal(long total) { + if (total <= 0L) { + throw new IllegalStateException("没有符合当前条件的订单记录"); + } + if (total > MAX_ORDER_EXPORT_ROWS) { + throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 " + MAX_ORDER_EXPORT_ROWS + + " 条,请缩小查询范围后分批导出"); + } + return total; + } + + /** + * 获取分页记录并把空记录统一为不可变空列表。 + * + * @param page 订单查询分页结果 + * @return 当前页导出行;分页或记录为空时返回空列表 + */ + private List safeRecords(Page page) { + return page == null || page.getRecords() == null ? List.of() : page.getRecords(); + } + + /** + * 读取模板明细、合计样式并移除占位行,为 SXSSF 连续追加订单腾出空间。 + * + * @param workbook 由既有订单模板创建的可编辑工作簿 + * @param query 已冻结的订单筛选条件,用于生成标题时间范围 + * @return 明细和合计写入共享的样式及金额汇总上下文 + */ + private OrderExportTemplateContext prepareTemplate(XSSFWorkbook workbook, HardOrderDto query) { + Sheet sheet = workbook.getSheetAt(0); + Row titleRow = sheet.getRow(ORDER_TITLE_ROW_INDEX); + Row headerRow = sheet.getRow(ORDER_HEADER_ROW_INDEX); + Row detailTemplate = sheet.getRow(ORDER_DETAIL_TEMPLATE_ROW_INDEX); + Row summaryTemplate = sheet.getRow(ORDER_SUMMARY_TEMPLATE_ROW_INDEX); + if (titleRow == null || headerRow == null || detailTemplate == null || summaryTemplate == null) { + throw new IllegalStateException("订单导出模板缺少标题、表头、明细或合计占位行"); + } + extendTemplateColumns(sheet, titleRow, headerRow, detailTemplate, summaryTemplate); + CellStyle[] detailStyles = readRowStyles(detailTemplate); + CellStyle[] summaryStyles = readRowStyles(summaryTemplate); + short detailRowHeight = detailTemplate.getHeight(); + short summaryRowHeight = summaryTemplate.getHeight(); + + titleRow.getCell(0).setCellValue(buildTitle(query)); + for (int index = sheet.getNumMergedRegions() - 1; index >= 0; index--) { + CellRangeAddress mergedRegion = sheet.getMergedRegion(index); + if (mergedRegion.getFirstRow() == ORDER_TITLE_ROW_INDEX + || mergedRegion.getFirstRow() >= ORDER_SUMMARY_TEMPLATE_ROW_INDEX) { + sheet.removeMergedRegion(index); + } + } + sheet.addMergedRegion(new CellRangeAddress(ORDER_TITLE_ROW_INDEX, ORDER_TITLE_ROW_INDEX, + 0, ORDER_EXPORT_COLUMN_COUNT - 1)); + sheet.removeRow(summaryTemplate); + sheet.removeRow(detailTemplate); + return new OrderExportTemplateContext(detailStyles, summaryStyles, detailRowHeight, summaryRowHeight); + } + + /** + * 在内存中把既有十二列表格扩展为用户基础信息、身份信息和订单信息完整列。 + * + * @param sheet 订单模板工作表 + * @param titleRow 标题模板行 + * @param headerRow 表头模板行 + * @param detailTemplate 明细占位行 + * @param summaryTemplate 合计占位行 + */ + private void extendTemplateColumns(Sheet sheet, Row titleRow, Row headerRow, Row detailTemplate, + Row summaryTemplate) { + if (ORDER_DETAIL_STYLE_SOURCE_COLUMNS.length != ORDER_EXPORT_COLUMN_COUNT + || ORDER_SUMMARY_STYLE_SOURCE_COLUMNS.length != ORDER_EXPORT_COLUMN_COUNT + || ORDER_EXPORT_COLUMN_WIDTHS.length != ORDER_EXPORT_COLUMN_COUNT) { + throw new IllegalStateException("订单导出列配置数量不一致"); + } + Cell titleTemplateCell = titleRow.getCell(0); + Cell headerTemplateCell = headerRow.getCell(0); + if (titleTemplateCell == null || headerTemplateCell == null) { + throw new IllegalStateException("订单导出模板缺少标题或表头样式"); + } + CellStyle titleStyle = titleTemplateCell.getCellStyle(); + CellStyle headerStyle = headerTemplateCell.getCellStyle(); + CellStyle[] detailSourceStyles = readOriginalTemplateStyles(detailTemplate); + CellStyle[] summarySourceStyles = readOriginalTemplateStyles(summaryTemplate); + for (int column = 0; column < ORDER_EXPORT_COLUMN_COUNT; column++) { + Cell titleCell = getOrCreateCell(titleRow, column); + titleCell.setCellStyle(titleStyle); + Cell headerCell = getOrCreateCell(headerRow, column); + headerCell.setCellStyle(headerStyle); + headerCell.setCellValue(ORDER_EXPORT_HEADERS[column]); + getOrCreateCell(detailTemplate, column) + .setCellStyle(detailSourceStyles[ORDER_DETAIL_STYLE_SOURCE_COLUMNS[column]]); + getOrCreateCell(summaryTemplate, column) + .setCellStyle(summarySourceStyles[ORDER_SUMMARY_STYLE_SOURCE_COLUMNS[column]]); + sheet.setColumnWidth(column, ORDER_EXPORT_COLUMN_WIDTHS[column] * 256); + } + } + + /** + * 获取模板行单元格,不存在时按指定列创建。 + * + * @param row 模板行 + * @param column Excel 零基列号 + * @return 已存在或新建的单元格 + */ + private Cell getOrCreateCell(Row row, int column) { + Cell cell = row.getCell(column); + return cell == null ? row.createCell(column) : cell; + } + + /** + * 在扩展列之前读取原十二列表格的样式快照,避免映射过程中覆盖后续样式来源。 + * + * @param row 原模板明细或合计占位行 + * @return 按原 A-L 列顺序排列的样式快照 + */ + private CellStyle[] readOriginalTemplateStyles(Row row) { + CellStyle[] styles = new CellStyle[ORDER_ORIGINAL_TEMPLATE_COLUMN_COUNT]; + for (int column = 0; column < ORDER_ORIGINAL_TEMPLATE_COLUMN_COUNT; column++) { + Cell cell = row.getCell(column); + if (cell == null) { + throw new IllegalStateException("订单导出模板缺少第 " + (column + 1) + " 列样式"); + } + styles[column] = cell.getCellStyle(); + } + return styles; + } + + /** + * 读取模板行每一列的样式,确保新增行延续原模板的字体、边框和对齐方式。 + * + * @param row 明细或合计模板行 + * @return 按 A-U 列顺序排列的样式数组 + */ + private CellStyle[] readRowStyles(Row row) { + CellStyle[] styles = new CellStyle[ORDER_EXPORT_COLUMN_COUNT]; + for (int column = 0; column < ORDER_EXPORT_COLUMN_COUNT; column++) { + Cell cell = row.getCell(column); + if (cell != null) { + styles[column] = cell.getCellStyle(); + } + } + return styles; + } + + /** + * 把一条订单写入模板明细行,并在写入边界完成业务编码和金额单位转换。 + * + * @param sheet 订单导出工作表 + * @param rowIndex 当前 Excel 零基行号 + * @param dataIndex 当前订单在全部导出数据中的零基序号 + * @param item 当前订单及其完整用户、商户导出资料 + * @param context 模板样式和跨批次金额汇总上下文 + */ + private void writeDetailRow(SXSSFSheet sheet, int rowIndex, long dataIndex, OrderExportRow item, + OrderExportTemplateContext context) { + HardOrderVo order = item.order; + UserVO user = item.user; + Row row = sheet.createRow(rowIndex); + row.setHeight(context.detailRowHeight); + setCell(row, 0, context.detailStyles[0], dataIndex + 1L); + setCell(row, 1, context.detailStyles[1], item.merchantName); + setCell(row, 2, context.detailStyles[2], formatUserId(order.getUserId())); + setCell(row, 3, context.detailStyles[3], resolveUserName(user, order)); + setCell(row, 4, context.detailStyles[4], user == null ? null : user.getEmail()); + setCell(row, 5, context.detailStyles[5], user == null ? order.getUserDeptName() + : StrUtil.blankToDefault(user.getDeptName(), order.getUserDeptName())); + setCell(row, 6, context.detailStyles[6], user == null ? null : joinPostNames(user.getPostList())); + setCell(row, 7, context.detailStyles[7], user == null ? order.getUserMobile() + : StrUtil.blankToDefault(user.getPhone(), order.getUserMobile())); + setCell(row, 8, context.detailStyles[8], user == null ? null : joinIdentityNames(user.getIdentityTypes())); + setCell(row, 9, context.detailStyles[9], user == null || user.getStudentInfo() == null + ? null : user.getStudentInfo().getStudentNo()); + setCell(row, 10, context.detailStyles[10], user == null || user.getStudentInfo() == null + ? null : user.getStudentInfo().getSchoolName()); + setCell(row, 11, context.detailStyles[11], user == null || user.getStudentInfo() == null + ? null : user.getStudentInfo().getClassName()); + setCell(row, 12, context.detailStyles[12], user == null || user.getStudentInfo() == null + ? null : user.getStudentInfo().getGuardianPhones()); + setCell(row, 13, context.detailStyles[13], user == null || user.getTeacherInfo() == null + ? null : user.getTeacherInfo().getTeacherNo()); + setCell(row, 14, context.detailStyles[14], order.getProductDesc()); + setCell(row, 15, context.detailStyles[15], resolvePayType(order.getPayType())); + setCell(row, 16, context.detailStyles[16], resolveHourType(order.getHourType())); + setCell(row, 17, context.detailStyles[17], formatPrice(order.getTotalPrice())); + setCell(row, 18, context.detailStyles[18], formatPrice(order.getExtraPrice())); + setCell(row, 19, context.detailStyles[19], formatPrice(order.getPayPrice())); + setCell(row, 20, context.detailStyles[20], formatDateTime(order.getPayTime())); + } + + /** + * 在所有订单明细之后写入金额合计,并合并金额列之前的说明区域。 + * + * @param sheet 订单导出工作表 + * @param rowIndex 合计行的 Excel 零基行号 + * @param context 模板样式和跨批次金额汇总上下文 + */ + private void writeSummaryRow(SXSSFSheet sheet, int rowIndex, OrderExportTemplateContext context) { + Row row = sheet.createRow(rowIndex); + row.setHeight(context.summaryRowHeight); + for (int column = 0; column < ORDER_EXPORT_COLUMN_COUNT; column++) { + setCell(row, column, context.summaryStyles[column], null); + } + row.getCell(0).setCellValue("合计"); + row.getCell(ORDER_TOTAL_PRICE_COLUMN_INDEX) + .setCellValue(context.summary.formatPrice(context.summary.totalPriceSum).doubleValue()); + row.getCell(ORDER_EXTRA_PRICE_COLUMN_INDEX) + .setCellValue(context.summary.formatPrice(context.summary.extraPriceSum).doubleValue()); + row.getCell(ORDER_PAY_PRICE_COLUMN_INDEX) + .setCellValue(context.summary.formatPrice(context.summary.payPriceSum).doubleValue()); + sheet.addMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, + ORDER_SUMMARY_MERGE_LAST_COLUMN_INDEX)); + } + + /** + * 将用户主键按文本写入 Excel,避免雪花主键超过双精度整数范围后发生尾数丢失。 + * + * @param userId UPMS 用户主键,允许为空 + * @return 十进制文本;输入为空时返回空 + */ + private String formatUserId(Long userId) { + return userId == null ? null : userId.toString(); + } + + /** + * 依次按姓名、昵称、登录账号和订单历史展示名确定导出姓名。 + * + * @param user UPMS 返回的完整用户资料,历史订单用户不存在时为空 + * @param order 当前订单,用于用户资料缺失时兼容历史展示字段 + * @return 用户导出姓名;所有来源均为空时返回空文本 + */ + private String resolveUserName(UserVO user, HardOrderVo order) { + if (user == null) { + return StrUtil.blankToDefault(order.getUsername(), ""); + } + if (StrUtil.isNotBlank(user.getName())) { + return user.getName(); + } + if (StrUtil.isNotBlank(user.getNickname())) { + return user.getNickname(); + } + return StrUtil.blankToDefault(user.getUsername(), StrUtil.blankToDefault(order.getUsername(), "")); + } + + /** + * 拼接用户岗位名称,多个岗位使用中文逗号分隔。 + * + * @param posts UPMS 完整用户资料中的岗位列表 + * @return 去空后的岗位名称;没有岗位时返回空文本 + */ + private String joinPostNames(List posts) { + if (posts == null || posts.isEmpty()) { + return ""; + } + return posts.stream().filter(Objects::nonNull).map(SysPost::getPostName) + .filter(StrUtil::isNotBlank).collect(Collectors.joining(",")); + } + + /** + * 将用户身份协议编码转换并拼接为中文名称,未知历史编码保留原值。 + * + * @param identityTypes UPMS 返回的用户身份协议编码 + * @return 以中文逗号分隔的身份名称;没有身份时返回空文本 + */ + private String joinIdentityNames(List identityTypes) { + if (identityTypes == null || identityTypes.isEmpty()) { + return ""; + } + return identityTypes.stream().map(identityType -> { + UserIdentityTypeEnum identity = UserIdentityTypeEnum.valueOfByType(identityType); + return identity == null ? identityType : identity.getDescription(); + }).filter(StrUtil::isNotBlank).collect(Collectors.joining(",")); + } + + /** + * 为单元格设置模板样式和值,空值仍创建单元格以保留完整边框。 + * + * @param row 目标行 + * @param column 目标列零基下标 + * @param style 模板样式,允许为空 + * @param value 单元格展示值,数字保持数字类型,其余按文本写入 + */ + private void setCell(Row row, int column, CellStyle style, Object value) { + Cell cell = row.createCell(column); + if (style != null) { + cell.setCellStyle(style); + } + if (value instanceof Number number) { + cell.setCellValue(number.doubleValue()); + } else if (value != null) { + cell.setCellValue(value.toString()); + } + } + + /** + * 生成订单导出标题;仅支付时间范围沿用旧模板的标题展示规则。 + * + * @param query 订单筛选条件 + * @return 包含支付时间范围的订单交易汇总标题,未选择范围时返回固定标题 + */ + private String buildTitle(HardOrderDto query) { + if (query.getPayTimeList() != null && query.getPayTimeList().length == 2 + && query.getPayTimeList()[0] != null && query.getPayTimeList()[1] != null) { + return LocalDateTimeUtil.format(query.getPayTimeList()[0], DatePattern.NORM_DATETIME_PATTERN) + + " 到 " + + LocalDateTimeUtil.format(query.getPayTimeList()[1], DatePattern.NORM_DATETIME_PATTERN) + + "订单交易汇总"; + } + return "订单交易汇总"; + } + + /** + * 将支付方式协议编码转换为现有枚举中文描述,未知编码原样保留以兼容历史数据。 + * + * @param payType 订单支付方式协议编码 + * @return 支付方式中文描述或原始编码 + */ + private String resolvePayType(String payType) { + if (StrUtil.isBlank(payType)) { + return payType; + } + HardPayTypeEnum value = HardPayTypeEnum.valueOfByType(payType); + return value == null ? payType : value.getDescription(); + } + + /** + * 将餐段协议编码转换为现有枚举中文描述,未知编码沿用旧导出规则输出空文本。 + * + * @param hourType 用餐时间段协议编码 + * @return 餐段中文描述、空值或空文本 + */ + private String resolveHourType(String hourType) { + if (StrUtil.isBlank(hourType)) { + return hourType; + } + HardHourTypeEnum value = HardHourTypeEnum.valueOfByType(hourType); + return value == null ? "" : value.getDescription(); + } + + /** + * 将订单金额从分转换为保留两位小数的元金额。 + * + * @param amount 订单金额,单位为分;空值表示模板单元格留空 + * @return 单位为元的金额;输入为空时返回空 + */ + private BigDecimal formatPrice(Number amount) { + return amount == null ? null : BigDecimal.valueOf(amount.longValue()) + .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + } + + /** + * 将订单支付时间转换为模板使用的标准日期时间文本。 + * + * @param dateTime 订单支付时间,允许为空 + * @return yyyy-MM-dd HH:mm:ss 文本;输入为空时返回空 + */ + private String formatDateTime(LocalDateTime dateTime) { + return dateTime == null ? null : LocalDateTimeUtil.format(dateTime, DatePattern.NORM_DATETIME_PATTERN); + } + + /** + * 上传完整订单工作簿并校验文件中心业务响应。 + * + * @param file 已关闭并可读取的完整 XLSX 临时文件 + * @param fileName 用户下载时使用的文件名 + * @return 文件中心提供的下载地址 + * @throws Exception 文件读取或远程上传失败时抛出 + */ + private String uploadExportFile(java.nio.file.Path file, String fileName) throws Exception { + MultipartFile exportFile = new MockMultipartFile("file", encodeUploadOriginalFilename(fileName), + ORDER_EXPORT_CONTENT_TYPE, Files.readAllBytes(file)); + R uploadResult = remoteFileService.innerUpload(exportFile, ORDER_EXPORT_FILE_DIR, null, + ORDER_EXPORT_FILE_TYPE); + if (uploadResult == null || !uploadResult.isOk() + || !(uploadResult.getData() instanceof Map resultData)) { + throw new IllegalStateException("文件中心未返回有效的订单导出结果"); + } + Object resultUrl = resultData.get("url"); + if (resultUrl == null || StrUtil.isBlank(resultUrl.toString())) { + throw new IllegalStateException("文件中心未返回订单导出下载地址"); + } + return resultUrl.toString(); + } + + /** + * 按文件服务现有约定转换中文原始文件名编码。 + * + * @param fileName 用户看到的 UTF-8 文件名 + * @return 文件中心上传接口使用的原始文件名 + */ + private String encodeUploadOriginalFilename(String fileName) { + return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); + } + + /** + * 冻结当前用户允许查看的设备类型、身份用户范围和单商户的子商户范围。 + * + * @param query 已复制、由当前任务独占的订单查询 + * @return 已补充设备、身份用户和商户权限范围的同一查询对象 + */ + private HardOrderDto prepareExportQuery(HardOrderDto query) { + CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(SecurityUtils.getUser()); + query.setMchTypeCodes(hardTypeInfo == null || hardTypeInfo.getHardTypeList() == null + ? List.of() : new ArrayList<>(hardTypeInfo.getHardTypeList())); + + applyIdentityQuery(query); + + List mchIds = query.getMchIds(); + if (mchIds != null && mchIds.size() == 1) { + Long parentMchId = mchIds.get(0); + R> response = remoteMchService.getByParentId(parentMchId); + if (response == null || !response.isOk()) { + throw new IllegalStateException("查询导出子商户范围失败"); + } + List expandedMchIds = new ArrayList<>(); + expandedMchIds.add(parentMchId); + if (response.getData() != null) { + response.getData().stream().map(MchManageVo::getId).filter(java.util.Objects::nonNull) + .forEach(expandedMchIds::add); + } + query.setMchIds(expandedMchIds.stream().distinct().toList()); + } + return query; + } + + /** + * 将身份筛选一次性转换为用户主键范围,后续所有订单分片复用同一条件。 + * + * @param query 当前导出任务独占的订单筛选条件,方法会清除已转换的身份字段 + */ + private void applyIdentityQuery(HardOrderDto query) { + if (!userIdentitySupport.hasIdentityQuery(query)) { + return; + } + query.setUserIds(userIdentitySupport.findIdentityUserIds(query, query.getUserId(), query.getUserIds())); + query.setUserId(null); + query.setIdentityTypes(null); + query.setStudentInfo(null); + query.setTeacherInfo(null); + query.setHasFace(null); + } + + /** + * 深复制订单导出筛选条件,避免请求对象中的集合或数组在后台任务开始前发生变化。 + * + * @param source Controller 接收的订单筛选条件,允许为空 + * @return 后台导出任务独占的筛选条件副本 + */ + private HardOrderDto copyQuery(HardOrderDto source) { + HardOrderDto target = new HardOrderDto(); + if (source == null) { + return target; + } + BeanUtils.copyProperties(source, target); + target.setDeptIds(copyList(source.getDeptIds())); + target.setUserIds(copyList(source.getUserIds())); + target.setUserDeptIds(copyList(source.getUserDeptIds())); + target.setMerchantDeptIds(copyList(source.getMerchantDeptIds())); + target.setOrderIds(copyList(source.getOrderIds())); + target.setHardIds(copyList(source.getHardIds())); + target.setMchIds(copyList(source.getMchIds())); + target.setMchTypeCodes(copyList(source.getMchTypeCodes())); + target.setOrderStatusCodes(copyList(source.getOrderStatusCodes())); + target.setOrderTypeCodes(copyList(source.getOrderTypeCodes())); + target.setOrderPayTypeCodes(copyList(source.getOrderPayTypeCodes())); + target.setExcludeOrderPayTypeCodes(copyList(source.getExcludeOrderPayTypeCodes())); + target.setHardTypeCodes(copyList(source.getHardTypeCodes())); + target.setRefundStatusCodes(copyList(source.getRefundStatusCodes())); + target.setSaleSourceCodes(copyList(source.getSaleSourceCodes())); + target.setSaleStatusCodes(copyList(source.getSaleStatusCodes())); + target.setScopeDeptIds(copyList(source.getScopeDeptIds())); + target.setAppWardUserIds(copyList(source.getAppWardUserIds())); + target.setIdentityTypes(copyList(source.getIdentityTypes())); + target.setCreateTimeList(cloneArray(source.getCreateTimeList())); + target.setUpdateTimeList(cloneArray(source.getUpdateTimeList())); + target.setVerificationTimeList(cloneArray(source.getVerificationTimeList())); + target.setPayTimeList(cloneArray(source.getPayTimeList())); + target.setStatisticsDateTimeList(cloneArray(source.getStatisticsDateTimeList())); + target.setRefundTimeList(cloneArray(source.getRefundTimeList())); + target.setApptUseDateTimeList(cloneArray(source.getApptUseDateTimeList())); + target.setStudentInfo(copyStudentInfo(source.getStudentInfo())); + target.setTeacherInfo(copyTeacherInfo(source.getTeacherInfo())); + return target; + } + + /** + * 复制可变列表,保留空值语义并隔离调用方后续修改。 + * + * @param source 原始列表,允许为空 + * @param 列表元素类型 + * @return 独立列表副本;输入为空时返回空 + */ + private List copyList(List source) { + return source == null ? null : new ArrayList<>(source); + } + + /** + * 复制日期时间范围数组,保留空值语义。 + * + * @param source 原始日期时间范围,允许为空 + * @return 独立数组副本;输入为空时返回空 + */ + private LocalDateTime[] cloneArray(LocalDateTime[] source) { + return source == null ? null : source.clone(); + } + + /** + * 复制学生身份扩展筛选条件,避免异步任务共享请求对象。 + * + * @param source 学生身份筛选条件,允许为空 + * @return 独立学生筛选副本;输入为空时返回空 + */ + private SysUserStudentDto copyStudentInfo(SysUserStudentDto source) { + if (source == null) { + return null; + } + SysUserStudentDto target = new SysUserStudentDto(); + BeanUtils.copyProperties(source, target); + return target; + } + + /** + * 复制老师身份扩展筛选条件,避免异步任务共享请求对象。 + * + * @param source 老师身份筛选条件,允许为空 + * @return 独立老师筛选副本;输入为空时返回空 + */ + private SysUserTeacherDto copyTeacherInfo(SysUserTeacherDto source) { + if (source == null) { + return null; + } + SysUserTeacherDto target = new SysUserTeacherDto(); + BeanUtils.copyProperties(source, target); + return target; + } + + /** + * 订单导出模板样式和跨批次金额汇总上下文。 + */ + private static final class OrderExportTemplateContext { + + /** 明细行 A-U 列的模板样式。 */ + private final CellStyle[] detailStyles; + + /** 合计行 A-U 列的模板样式。 */ + private final CellStyle[] summaryStyles; + + /** 明细模板行高度,单位为 POI twip。 */ + private final short detailRowHeight; + + /** 合计模板行高度,单位为 POI twip。 */ + private final short summaryRowHeight; + + /** 跨分页累计的订单金额汇总。 */ + private final OrderExportSummary summary = new OrderExportSummary(); + + /** + * 创建订单导出模板上下文。 + * + * @param detailStyles 明细行 A-U 列样式 + * @param summaryStyles 合计行 A-U 列样式 + * @param detailRowHeight 明细行高度 + * @param summaryRowHeight 合计行高度 + */ + private OrderExportTemplateContext(CellStyle[] detailStyles, CellStyle[] summaryStyles, + short detailRowHeight, short summaryRowHeight) { + this.detailStyles = detailStyles; + this.summaryStyles = summaryStyles; + this.detailRowHeight = detailRowHeight; + this.summaryRowHeight = summaryRowHeight; + } + } + + /** + * 单条订单导出行,组合数据库订单、UPMS 完整用户资料和钱包商户名称。 + */ + private static final class OrderExportRow { + + /** 当前数据权限和筛选条件命中的订单记录。 */ + private final HardOrderVo order; + + /** UPMS 返回的完整用户导出资料;历史用户不存在或不可见时为空。 */ + private final UserVO user; + + /** 钱包服务返回的商户名称;历史商户不存在时为空。 */ + private final String merchantName; + + /** + * 创建订单导出行。 + * + * @param order 当前订单记录 + * @param user 与订单用户主键匹配的完整用户资料 + * @param merchantName 与订单商户主键匹配的商户名称 + */ + private OrderExportRow(HardOrderVo order, UserVO user, String merchantName) { + this.order = order; + this.user = user; + this.merchantName = merchantName; + } + } + + /** + * 订单导出跨分页金额汇总器,所有金额均按数据库分单位累计。 + */ + private static final class OrderExportSummary { + + /** 商品总价累计值,单位为分。 */ + private BigDecimal totalPriceSum = BigDecimal.ZERO; + + /** 额外支付累计值,单位为分。 */ + private BigDecimal extraPriceSum = BigDecimal.ZERO; + + /** 实际支付累计值,单位为分。 */ + private BigDecimal payPriceSum = BigDecimal.ZERO; + + /** + * 累计当前订单的三个金额字段,历史空值按零分处理。 + * + * @param item 当前订单及其导出关联资料 + */ + private void add(OrderExportRow item) { + totalPriceSum = addAmount(totalPriceSum, item.order.getTotalPrice()); + extraPriceSum = addAmount(extraPriceSum, item.order.getExtraPrice()); + payPriceSum = addAmount(payPriceSum, item.order.getPayPrice()); + } + + /** + * 将非空分金额加入当前累计值。 + * + * @param current 当前累计金额,单位为分 + * @param amount 待累计金额,单位为分;允许为空 + * @return 累加后的分金额 + */ + private BigDecimal addAmount(BigDecimal current, Number amount) { + return amount == null ? current : current.add(BigDecimal.valueOf(amount.longValue())); + } + + /** + * 将累计的分金额转换为两位小数元金额。 + * + * @param amount 累计金额,单位为分 + * @return 单位为元的两位小数金额 + */ + private BigDecimal formatPrice(BigDecimal amount) { + return amount.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java index db9b15389..589566fa7 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java @@ -1909,16 +1909,21 @@ public class HardOrderServiceImpl extends ServiceImpl= 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 order by t.trigger_time asc, t.id asc + + + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml index f10256ab7..f080f7227 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml @@ -161,6 +161,12 @@ #{v} + + and o.user_id in + + #{v} + + @@ -928,7 +934,7 @@ 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 @@ -1075,6 +1081,12 @@ #{v} + + and o.user_id in + + #{v} + + and o.pay_time between #{dto.payTimeList[0]} and #{dto.payTimeList[1]} @@ -1182,6 +1194,12 @@ #{v} + + and o.user_id in + + #{v} + + and o.order_status in diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml index 652683b23..0afa5b1f4 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml @@ -167,6 +167,13 @@ #{v} + + + and a.id in + + #{v} + + and a.merchant_id in @@ -362,11 +369,15 @@ order by a.verification_time desc, a.id desc + + order by a.appt_use_date desc, a.hour_type asc, a.id desc + order by a.update_time desc, a.id desc - order by a.create_time desc, a.user_id desc + + order by a.create_time desc, a.user_id desc, a.id desc @@ -388,6 +399,9 @@ order by a.verification_time desc, a.id desc + + order by a.appt_use_date desc, a.hour_type asc, a.id desc + order by a.update_time desc, a.id desc diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java index b590483db..c25519292 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java @@ -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 diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java index c3b5c2c4c..4c5a34135 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java @@ -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 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()); + } + /** * 构造固定订单和售后单的自动退款任务。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java index e2f3c14ce..e60e8ce98 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java @@ -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()); + } + /** * 构造旧消费机通用核销接口请求。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdMchFzRelationSaveDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdMchFzRelationSaveDto.java new file mode 100644 index 000000000..146584fd9 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdMchFzRelationSaveDto.java @@ -0,0 +1,59 @@ +package com.bsd.pjcloud.thirdparty.dto.pay; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 商户分账关系管理参数。 + * + *

租户和支付平台由 thirdparty 根据 {@code paySettingId} 重新读取;二级商户、 + * 支付宝平台账号业务标识及授权上下文属于关系事实,由管理端在该子资源中维护; + * SDK 私钥和支付宝公钥仍由 thirdparty 平台统一配置持有。

+ */ +@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; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java index 0584deec3..3a0eedd75 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java @@ -40,14 +40,18 @@ public class ThirdMchFzRelationEntity extends Model { 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; /** 渠道绑定稳定幂等号;支付宝重试必须保持不变,微信用于本地审计。 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdMchFzRelationVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdMchFzRelationVo.java new file mode 100644 index 000000000..c07bdfe38 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdMchFzRelationVo.java @@ -0,0 +1,51 @@ +package com.bsd.pjcloud.thirdparty.vo.pay; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 管理端展示的商户分账关系。 + * + *

接收方账号已脱敏,绑定请求和响应快照不返回浏览器,避免把渠道报文 + * 或内部配置暴露给管理端页面。

+ */ +@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; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java index 218e8d886..5c01d96c8 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java @@ -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 accounts = new LinkedHashMap<>(); - - /** - * 直付通平台抽佣接收支付宝账号,类型使用 userId 或 loginName。 - */ - private String royaltyTransIn; - - /** - * 直付通平台抽佣接收账号类型,支付宝支持 userId 或 loginName。 - */ - private String royaltyTransInType; - - /** - * 直付通平台抽佣接收方名称,用于支付宝分账请求描述。 - */ - private String royaltyTransInName; - } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdMchFzRelationController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdMchFzRelationController.java new file mode 100644 index 000000000..3718e57e9 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdMchFzRelationController.java @@ -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; + +/** + * 商户支付分账关系管理接口。 + * + *

关系属于支付配置的一对多子资源,二级商户、支付宝平台账号和代调用授权也归关系所有; + * 渠道 AppID 由 thirdparty 的平台统一配置解析,关系中的账号键只作为业务标识和审计快照。

+ */ +@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(@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 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 remove(@RequestBody List 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 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> result = remoteMchService.getSelfMchIdList(SecurityUtils.getUser().getId(), MerchantTypeEnum.all()); + if (result == null || !result.isOk() || result.getData() == null || !result.getData().contains(mchId)) { + throw new IllegalArgumentException("当前用户无权管理该商户分账关系"); + } + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java index 496521112..b94029ad1 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java @@ -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 listForManage(Long mchId, Long paySettingId); + + /** + * 新增或更新关系管理记录;渠道身份变化时创建新版本,避免覆盖历史支付订单引用的关系事实。 + * + * @param dto 管理端关系参数 + * @return 保存后的脱敏关系;身份变化时返回新关系主键 + */ + ThirdMchFzRelationVo saveForManage(ThirdMchFzRelationSaveDto dto); + + /** + * 删除关系;已绑定关系只标记为历史关系,默认关系删除前必须自动切换到可用替代关系。 + * + * @param ids 关系主键列表 + * @return 全部处理成功时返回 true + */ + boolean removeForManage(List 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); + + /** + * 使用固定关系生成订单级渠道配置快照,供渠道调用及后续查单、退款恢复使用。 + * + *

该快照只写入 thirdparty 支付订单,不回写 {@code mch_pay_setting.setting_json}; + * 其中二级商户号、支付宝账号业务标识和授权令牌全部来源于关系记录;支付宝 SDK 密钥仍读取平台统一配置。

+ * + * @param setting 当前启用的支付配置,只提供商户、平台和配置主键 + * @param relation 已校验的默认关系事实 + * @return 包含关系渠道参数的独立支付配置快照 + */ + MchPaySettingVo createPaymentSettingSnapshot(MchPaySettingVo setting, ThirdMchFzRelationEntity relation); + /** * 按支付订单保存的关系主键读取历史关系快照,供分账和退分账使用。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java index 4d5cb3b16..26614ace3 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java @@ -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); } /** diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java index dc955f010..34367ceee 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java @@ -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); } /** diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImpl.java index f116dae81..3ea4cde1c 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImpl.java @@ -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()); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java index d6941bbb2..d336663ed 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java @@ -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 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 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 *

支付宝以 out_request_no 保证幂等;只有明确业务错误才把查询响应改写为失败, * 网络、系统和限频错误继续保留退款中供下一轮恢复。

* - * @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() @@ -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()); } + /** + * 校验新支付只能使用当前支付配置唯一默认且已绑定的关系。 + * + *

历史关系允许旧订单退款和退分账,但绝不能继续承接新支付;因此支付创建校验 + * 比历史订单分账校验额外要求关系状态、默认标记和支付配置主键完全一致。

+ * + * @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} */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java index 890abb45f..3a0498814 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java @@ -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 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; } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatMicropayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatMicropayServiceImpl.java index 58b7320ae..31cc74baf 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatMicropayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatMicropayServiceImpl.java @@ -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 { diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatNativePayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatNativePayServiceImpl.java index ea9422594..53a3f2945 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatNativePayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatNativePayServiceImpl.java @@ -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); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImpl.java index a16db618e..d24d76815 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImpl.java @@ -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; diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/fz/ThirdAppPlusWechatFzServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/fz/ThirdAppPlusWechatFzServiceImpl.java index 125e7336e..43731e6ff 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/fz/ThirdAppPlusWechatFzServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/fz/ThirdAppPlusWechatFzServiceImpl.java @@ -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) { diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java index 959672cc2..f6a107120 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java @@ -24,8 +24,7 @@ import java.util.List; */ @Service @Validated -public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl - implements ThirdAggregatePaySessionService { +public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl implements ThirdAggregatePaySessionService { /** * 按聚合会话编号读取当前租户有效记录。 @@ -76,7 +75,7 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl * * @param sessionId 聚合会话主键 - * @param status 目标终态 + * @param status 目标终态 * @return CAS 更新成功时返回 {@code true} */ @Override diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java index 29cc83982..ced357d0a 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java @@ -1,5 +1,6 @@ package com.bsd.pjcloud.thirdparty.service.impl.pay; +import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; import com.alipay.api.AlipayClient; @@ -23,10 +24,12 @@ import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationSceneEnum; import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationStatusEnum; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; +import com.bsd.pjcloud.thirdparty.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.ThirdMchFzRelationService; import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdMchFzRelationVo; 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; @@ -44,9 +47,10 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.dao.DuplicateKeyException; import java.time.LocalDateTime; -import java.util.ArrayList; +import java.math.BigDecimal; import java.util.List; import java.util.Objects; +import java.util.Set; /** * 商户微信/支付宝默认平台分佣关系实现。 @@ -59,28 +63,414 @@ import java.util.Objects; @RequiredArgsConstructor public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService { - /** 微信支付平台持久化编码。 */ + /** + * 微信支付平台持久化编码。 + */ private static final String WECHAT_TYPE = AppPlusPlatformPayType.WECHAT.getType(); - /** 支付宝支付平台持久化编码。 */ + /** + * 支付宝支付平台持久化编码。 + */ private static final String ALIPAY_TYPE = AppPlusPlatformPayType.ALI_PAY.getType(); - /** 关系绑定的固定业务场景。 */ + /** + * 关系绑定的固定业务场景。 + */ private static final String DEFAULT_SCENE = ThirdMchFzRelationSceneEnum.DEFAULT_PLATFORM_COMMISSION.getCode(); - /** 本地关系绑定任务的优先级,需早于支付创建后的分账任务。 */ + /** + * 本地关系绑定任务的优先级,需早于支付创建后的分账任务。 + */ private static final int RELATION_TASK_PRIORITY = 150; + /** + * 支付宝关系绑定幂等号的最大长度,渠道接口要求不超过32个字符。 + */ + private static final int ALIPAY_OUT_REQUEST_NO_MAX_LENGTH = 32; + /** + * 管理端允许配置的支付宝接收方类型,编码直接传给支付宝 SDK。 + */ + private static final Set ALIPAY_RECEIVER_TYPES = Set.of("userId", "loginName", "openId", "cardAliasNo"); + /** + * 管理端允许配置的微信接收方类型,编码对应微信 V3 分账 SDK 枚举。 + */ + private static final Set WECHAT_RECEIVER_TYPES = Set.of("MERCHANT_ID", "PERSONAL_OPENID", "PERSONAL_SUB_OPENID"); - /** 关系事实 Mapper。 */ + /** + * 关系事实 Mapper。 + */ private final ThirdMchFzRelationMapper relationMapper; - /** 关系绑定任务写入器。 */ + /** + * 关系绑定任务写入器。 + */ private final ThirdPaymentTaskEnqueuer taskEnqueuer; - /** 微信服务商分账 SDK。 */ + /** + * 微信服务商分账 SDK。 + */ private final ProfitsharingService profitsharingService; - /** 应用级支付宝、微信平台配置。 */ + /** + * 应用级支付宝、微信平台配置。 + */ private final AppPlusPayConfig appPlusPayConfig; - /** 钱包商户内部接口,用于 Worker 执行时读取当前支付宝授权 token。 */ + /** + * 钱包商户内部接口,用于校验支付配置和管理端商户归属。 + */ private final RemoteMchService remoteMchService; /** - * 物化当前商户启用且配置抽佣比例的默认关系,并把旧配置标记为历史关系。 + * 查询支付配置下的一对多关系,接收方账号只返回脱敏值。 + * + * @param mchId 商户主键 + * @param paySettingId 支付配置主键 + * @return 默认关系优先的关系列表 + */ + @Override + public List listForManage(Long mchId, Long paySettingId) { + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId == null || mchId == null || paySettingId == null) { + throw new IllegalArgumentException("商户、支付配置和租户不能为空"); + } + MchManageVo merchant = requireMerchant(mchId); + if (merchant.getPaySettingList() == null || merchant.getPaySettingList().stream() + .noneMatch(item -> paySettingId.equals(item.getId()))) { + throw new IllegalArgumentException("支付配置不属于指定商户"); + } + return relationMapper.selectList(new LambdaQueryWrapper() + .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) + .eq(ThirdMchFzRelationEntity::getMchId, mchId) + .eq(ThirdMchFzRelationEntity::getPaySettingId, paySettingId) + .eq(ThirdMchFzRelationEntity::getRelationScene, DEFAULT_SCENE) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0") + .orderByDesc(ThirdMchFzRelationEntity::getIsDefault) + .orderByDesc(ThirdMchFzRelationEntity::getCreateTime)) + .stream().map(this::toManageVo).toList(); + } + + /** + * 新增或编辑关系配置。渠道身份变化时创建新关系版本,非身份渠道字段变化时重置原关系并重新绑定。 + * + *

支付订单会永久引用创建时的关系主键,因此接收方账号、类型、二级商户或渠道应用变化时 + * 不能覆盖原记录;旧记录保留为 {@code SUPERSEDED},新记录使用独立幂等号等待 Quartz 绑定。

+ * + * @param dto 管理端关系参数;编辑时接收方标识为空表示保留服务端完整值 + * @return 保存后的脱敏关系;身份变化时返回新关系主键 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public ThirdMchFzRelationVo saveForManage(ThirdMchFzRelationSaveDto dto) { + if (dto == null || dto.getMchId() == null || dto.getPaySettingId() == null) { + throw new IllegalArgumentException("商户和支付配置不能为空"); + } + Long tenantId = TenantContextHolder.getTenantId(); + MchManageVo merchant = requireMerchant(dto.getMchId()); + MchPaySettingVo setting = merchant.getPaySettingList() == null ? null : merchant.getPaySettingList().stream().filter(item -> dto.getPaySettingId().equals(item.getId())).findFirst().orElse(null); + if (setting == null || !isEligible(setting)) { + throw new IllegalArgumentException("支付配置未启用或渠道配置不完整"); + } + ThirdMchFzRelationEntity relation; + boolean inheritDefault = false; + if (dto.getId() != null) { + ThirdMchFzRelationEntity existing = relationMapper.selectOne(new LambdaQueryWrapper() + .eq(ThirdMchFzRelationEntity::getId, dto.getId()) + .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) + .eq(ThirdMchFzRelationEntity::getMchId, dto.getMchId()) + .eq(ThirdMchFzRelationEntity::getPaySettingId, dto.getPaySettingId()) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0")); + if (existing == null) { + throw new IllegalArgumentException("分账关系不存在"); + } + if (ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode().equals(existing.getStatus())) { + throw new IllegalArgumentException("历史分账关系不能编辑,请维护当前关系"); + } + if (!matchesCurrentSetting(existing, setting)) { + throw new IllegalArgumentException("历史渠道配置下的关系不能重新用于当前支付配置"); + } + boolean wasDefault = YesNoEnum.YES.getCode().equals(existing.getIsDefault()); + relation = updateManageRelation(tenantId, dto, setting, existing); + inheritDefault = wasDefault && !Objects.equals(existing.getId(), relation.getId()); + } else { + validateManageReceiver(dto, setting); + validateRelationRatio(dto.getFzRatio(), true); + relation = buildManageRelation(tenantId, dto, setting); + try { + relationMapper.insert(relation); + } catch (DuplicateKeyException exception) { + throw new IllegalArgumentException("相同渠道接收方关系已存在,请直接设为默认"); + } + } + // 首条关系由服务端自动成为默认关系,不能把支付可用性建立在前端是否正确提交开关上。 + if (inheritDefault || YesNoEnum.YES.getCode().equals(dto.getIsDefault()) + || !hasDefaultRelation(tenantId, dto.getPaySettingId())) { + setDefault(relation); + } + if (relation.getFzRatio() != null && relation.getFzRatio() > 0 + && !ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(relation.getStatus())) { + taskEnqueuer.enqueueRelationBind(tenantId, relation.getId(), LocalDateTime.now(), RELATION_TASK_PRIORITY); + } + ThirdMchFzRelationEntity latest = relationMapper.selectById(relation.getId()); + return toManageVo(latest == null ? relation : latest); + } + + /** + * 保存已有关系。渠道身份改变时生成新版本,展示名称或微信关系类型改变时重置原关系等待重绑。 + * + * @param tenantId 当前租户ID + * @param dto 管理端编辑参数 + * @param setting 已重新校验的支付配置 + * @param existing 数据库中的完整原关系 + * @return 原关系的新状态,或身份变化后新建的关系版本 + */ + private ThirdMchFzRelationEntity updateManageRelation(Long tenantId, ThirdMchFzRelationSaveDto dto, + MchPaySettingVo setting, ThirdMchFzRelationEntity existing) { + ThirdMchFzRelationSaveDto effective = normalizeManageEdit(dto, setting, existing); + validateManageReceiver(effective, setting); + validateRelationRatio(effective.getFzRatio(), false); + String channelAppId = resolveChannelAppId(setting); + if (hasChannelIdentityChanged(existing, effective, channelAppId)) { + ThirdMchFzRelationEntity replacement = buildManageRelation(tenantId, effective, setting); + try { + relationMapper.insert(replacement); + } catch (DuplicateKeyException exception) { + throw new IllegalArgumentException("相同渠道接收方关系已存在,请直接设为默认"); + } + supersedeRelation(existing); + return replacement; + } + return updateCurrentRelation(tenantId, effective, setting, existing); + } + + /** + * 合并编辑参数与数据库完整值。管理端永远拿不到完整接收账号,因此空账号表示保持原值而不是清空。 + * + * @param dto 管理端编辑参数 + * @param setting 当前支付配置 + * @param existing 原关系完整记录 + * @return 可直接校验和落库的完整关系参数 + */ + private ThirdMchFzRelationSaveDto normalizeManageEdit(ThirdMchFzRelationSaveDto dto, + MchPaySettingVo setting, ThirdMchFzRelationEntity existing) { + ThirdMchFzRelationSaveDto effective = new ThirdMchFzRelationSaveDto(); + effective.setId(dto.getId()); + effective.setMchId(dto.getMchId()); + effective.setPaySettingId(dto.getPaySettingId()); + effective.setChannelSubMchId(StrUtil.isBlank(dto.getChannelSubMchId()) + ? existing.getChannelSubMchId() : StrUtil.trim(dto.getChannelSubMchId())); + String accountKey = dto.getAccountKey() == null ? existing.getAccountKey() : StrUtil.trim(dto.getAccountKey()); + effective.setAccountKey(StrUtil.isBlank(accountKey) ? null : accountKey); + effective.setAppAuthToken(StrUtil.isBlank(dto.getAppAuthToken()) + ? existing.getAppAuthToken() : StrUtil.trim(dto.getAppAuthToken())); + String receiverType = StrUtil.isBlank(dto.getReceiverType()) + ? existing.getReceiverType() : StrUtil.trim(dto.getReceiverType()); + if (!Objects.equals(existing.getReceiverType(), receiverType) && StrUtil.isBlank(dto.getReceiverAccount())) { + throw new IllegalArgumentException("修改接收方类型时必须输入完整的新接收方标识"); + } + effective.setReceiverType(receiverType); + effective.setReceiverAccount(StrUtil.isBlank(dto.getReceiverAccount()) + ? existing.getReceiverAccount() : StrUtil.trim(dto.getReceiverAccount())); + effective.setReceiverName(dto.getReceiverName() == null + ? existing.getReceiverName() : StrUtil.trim(dto.getReceiverName())); + effective.setFzRatio(dto.getFzRatio() == null ? existing.getFzRatio() : dto.getFzRatio()); + if (WECHAT_TYPE.equals(setting.getPlatformType())) { + effective.setChannelRelationType(StrUtil.isBlank(dto.getChannelRelationType()) + ? existing.getChannelRelationType() : StrUtil.trim(dto.getChannelRelationType())); + } + effective.setIsDefault(dto.getIsDefault()); + return effective; + } + + /** + * 判断会影响历史订单退款与退分账归属的渠道身份是否变化。 + * + * @param existing 原关系 + * @param effective 合并后的完整编辑参数 + * @param channelAppId 平台统一配置解析出的真实渠道应用ID + * @return 任一身份字段变化时返回 {@code true} + */ + private boolean hasChannelIdentityChanged(ThirdMchFzRelationEntity existing, + ThirdMchFzRelationSaveDto effective, String channelAppId) { + return !Objects.equals(existing.getChannelAppId(), channelAppId) + || !Objects.equals(existing.getChannelSubMchId(), effective.getChannelSubMchId()) + || !Objects.equals(existing.getReceiverType(), effective.getReceiverType()) + || !Objects.equals(existing.getReceiverAccount(), effective.getReceiverAccount()); + } + + /** + * 原地更新不影响历史退款身份的字段;名称或微信关系类型变化时使用新幂等号重新绑定。 + * + *

若 Worker 已把关系推进到 {@code BINDING},本次保存会拒绝并要求稍后重试,避免旧请求完成后 + * 覆盖管理员刚保存的新渠道字段。状态更新使用原状态作为 CAS 条件。

+ * + * @param tenantId 当前租户ID + * @param effective 合并后的完整编辑参数 + * @param setting 当前支付配置 + * @param existing 原关系 + * @return 更新后的关系内存快照 + */ + private ThirdMchFzRelationEntity updateCurrentRelation(Long tenantId, ThirdMchFzRelationSaveDto effective, + MchPaySettingVo setting, ThirdMchFzRelationEntity existing) { + boolean rebindRequired = !Objects.equals(existing.getReceiverName(), effective.getReceiverName()) + || (WECHAT_TYPE.equals(setting.getPlatformType()) + && !Objects.equals(existing.getChannelRelationType(), effective.getChannelRelationType())); + if (rebindRequired && ThirdMchFzRelationStatusEnum.BINDING.getCode().equals(existing.getStatus())) { + throw new IllegalStateException("分账关系正在绑定,请等待本次绑定完成后再修改"); + } + ThirdMchFzRelationEntity updated = BeanUtil.toBean(existing, ThirdMchFzRelationEntity.class); + updated.setAccountKey(effective.getAccountKey()); + updated.setAppAuthToken(effective.getAppAuthToken()); + updated.setReceiverName(effective.getReceiverName()); + updated.setFzRatio(effective.getFzRatio()); + updated.setChannelRelationType(effective.getChannelRelationType()); + LambdaUpdateWrapper update = new LambdaUpdateWrapper() + .eq(ThirdMchFzRelationEntity::getId, existing.getId()) + .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0") + .set(ThirdMchFzRelationEntity::getAccountKey, updated.getAccountKey()) + .set(ThirdMchFzRelationEntity::getAppAuthToken, updated.getAppAuthToken()) + .set(ThirdMchFzRelationEntity::getReceiverName, updated.getReceiverName()) + .set(ThirdMchFzRelationEntity::getFzRatio, updated.getFzRatio()) + .set(ThirdMchFzRelationEntity::getChannelRelationType, updated.getChannelRelationType()); + if (rebindRequired) { + updated.setOutRequestNo(newRelationRequestNo(setting.getPlatformType(), existing.getId())); + updated.setStatus(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode()); + updated.setBindReq(buildBindRequestSnapshot(updated)); + updated.setBindRes(null); + updated.setBindError(null); + updated.setLastExternalStatus(null); + updated.setLastQueryTime(null); + updated.setBoundTime(null); + update.eq(ThirdMchFzRelationEntity::getStatus, existing.getStatus()) + .set(ThirdMchFzRelationEntity::getOutRequestNo, updated.getOutRequestNo()) + .set(ThirdMchFzRelationEntity::getStatus, updated.getStatus()) + .set(ThirdMchFzRelationEntity::getBindReq, updated.getBindReq()) + .set(ThirdMchFzRelationEntity::getBindRes, null) + .set(ThirdMchFzRelationEntity::getBindError, null) + .set(ThirdMchFzRelationEntity::getLastExternalStatus, null) + .set(ThirdMchFzRelationEntity::getLastQueryTime, null) + .set(ThirdMchFzRelationEntity::getBoundTime, null); + } + if (relationMapper.update(null, update) == 0) { + throw new IllegalStateException("分账关系状态已变化,请刷新后重试"); + } + return updated; + } + + /** + * 将被替换关系退出新支付默认候选;记录仍保留给历史订单退款和退分账读取。 + * + * @param existing 被新版本替换的关系 + */ + private void supersedeRelation(ThirdMchFzRelationEntity existing) { + int updated = relationMapper.update(null, new LambdaUpdateWrapper() + .eq(ThirdMchFzRelationEntity::getId, existing.getId()) + .eq(ThirdMchFzRelationEntity::getTenantId, existing.getTenantId()) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0") + .set(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.NO.getCode()) + .set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode())); + if (updated == 0) { + throw new IllegalStateException("原分账关系状态已变化,请刷新后重试"); + } + existing.setIsDefault(YesNoEnum.NO.getCode()); + existing.setStatus(ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode()); + } + + /** + * 判断当前支付配置是否已经存在未删除的默认关系。 + */ + private boolean hasDefaultRelation(Long tenantId, Long paySettingId) { + return relationMapper.selectCount(new LambdaQueryWrapper() + .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) + .eq(ThirdMchFzRelationEntity::getPaySettingId, paySettingId) + .eq(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode()) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0")) > 0; + } + + /** + * 删除关系;已绑定关系保留为历史事实,只移出默认候选,避免历史订单无法退分账。 + * + *

默认关系删除前必须在同一支付配置下找到另一个已绑定且启用抽佣的关系, + * 并在当前事务内先切换默认关系,再将旧关系标记为历史。这样管理端可以完整维护关系, + * 同时不会出现“默认关系已删但新支付没有可用分账关系”的中间状态。

+ * + * @param ids 关系主键 + * @return 是否全部处理成功 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean removeForManage(List ids) { + if (ids == null || ids.isEmpty()) { + return true; + } + List relationIds = ids.stream().filter(Objects::nonNull).distinct().toList(); + if (relationIds.isEmpty()) { + return true; + } + Long tenantId = TenantContextHolder.getTenantId(); + for (Long id : relationIds) { + ThirdMchFzRelationEntity relation = relationMapper.selectOne(new LambdaQueryWrapper() + .eq(ThirdMchFzRelationEntity::getId, id) + .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0")); + if (relation == null) { + continue; + } + if (YesNoEnum.YES.getCode().equals(relation.getIsDefault())) { + ThirdMchFzRelationEntity replacement = relationMapper.selectOne(new LambdaQueryWrapper() + .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) + .eq(ThirdMchFzRelationEntity::getMchId, relation.getMchId()) + .eq(ThirdMchFzRelationEntity::getPaySettingId, relation.getPaySettingId()) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0") + .eq(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BOUND.getCode()) + .eq(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.NO.getCode()) + .gt(ThirdMchFzRelationEntity::getFzRatio, 0D) + .notIn(ThirdMchFzRelationEntity::getId, relationIds) + .ne(ThirdMchFzRelationEntity::getId, relation.getId()) + .orderByDesc(ThirdMchFzRelationEntity::getCreateTime) + .last("LIMIT 1")); + if (replacement == null) { + throw new IllegalArgumentException("默认分账关系没有可用替代关系,请先新增并绑定一条启用抽佣的关系"); + } + // setDefault 会在当前事务内清理旧默认标记,再把候选关系设为默认。 + setDefault(replacement); + } + LambdaUpdateWrapper update = new LambdaUpdateWrapper() + .eq(ThirdMchFzRelationEntity::getId, id) + .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) + .set(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.NO.getCode()); + if (relation.getBoundTime() != null || ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(relation.getStatus())) { + update.set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode()); + } else { + update.set(ThirdMchFzRelationEntity::getDelFlag, "1"); + } + relationMapper.update(null, update); + } + return true; + } + + /** + * 人工重试关系绑定,保留原关系主键和支付宝 out_request_no。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean retryForManage(Long id) { + Long tenantId = TenantContextHolder.getTenantId(); + ThirdMchFzRelationEntity relation = relationMapper.selectOne(new LambdaQueryWrapper() + .eq(ThirdMchFzRelationEntity::getId, id) + .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0")); + if (relation == null) { + throw new IllegalArgumentException("分账关系不存在"); + } + if (ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode().equals(relation.getStatus())) { + throw new IllegalArgumentException("历史分账关系不能重试,请新增关系后绑定"); + } + relationMapper.update(null, new LambdaUpdateWrapper() + .eq(ThirdMchFzRelationEntity::getId, id) + .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) + .in(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BIND_FAILED.getCode(), ThirdMchFzRelationStatusEnum.BINDING.getCode(), ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode()) + .set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode()) + .set(ThirdMchFzRelationEntity::getBindError, null)); + taskEnqueuer.enqueueRelationBind(tenantId, id, LocalDateTime.now(), RELATION_TASK_PRIORITY); + return true; + } + + /** + * 同步支付配置生命周期;渠道身份已迁入关系表,因此这里只停用已删除或禁用配置的默认关系。 * * @param merchant 商户内部快照 */ @@ -94,62 +484,8 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService if (tenantId == null) { throw new IllegalStateException("商户分账关系租户不能为空"); } - List settings = merchant.getPaySettingList() == null - ? List.of() : merchant.getPaySettingList(); - List currentSettingIds = new ArrayList<>(); - for (MchPaySettingVo setting : settings) { - validateFzRatio(setting); - if (!isEligible(setting)) { - continue; - } - currentSettingIds.add(setting.getId()); - ThirdMchFzRelationEntity relation = buildRelation(tenantId, merchant, setting); - ThirdMchFzRelationEntity existing = relationMapper.selectOne(new LambdaQueryWrapper() - .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) - .eq(ThirdMchFzRelationEntity::getPaySettingId, setting.getId()) - .eq(ThirdMchFzRelationEntity::getPlatformType, relation.getPlatformType()) - .eq(ThirdMchFzRelationEntity::getChannelAppId, relation.getChannelAppId()) - .eq(ThirdMchFzRelationEntity::getChannelSubMchId, relation.getChannelSubMchId()) - .eq(ThirdMchFzRelationEntity::getReceiverType, relation.getReceiverType()) - .eq(ThirdMchFzRelationEntity::getReceiverAccount, relation.getReceiverAccount()) - .eq(ThirdMchFzRelationEntity::getRelationScene, DEFAULT_SCENE) - .eq(ThirdMchFzRelationEntity::getDelFlag, "0")); - if (existing == null) { - try { - relationMapper.insert(relation); - existing = relation; - } catch (DuplicateKeyException ignored) { - // 多个钱包实例并发保存同一配置时,数据库唯一键选出唯一关系事实,失败方复用胜出记录。 - existing = relationMapper.selectOne(new LambdaQueryWrapper() - .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) - .eq(ThirdMchFzRelationEntity::getPaySettingId, setting.getId()) - .eq(ThirdMchFzRelationEntity::getPlatformType, relation.getPlatformType()) - .eq(ThirdMchFzRelationEntity::getChannelAppId, relation.getChannelAppId()) - .eq(ThirdMchFzRelationEntity::getChannelSubMchId, relation.getChannelSubMchId()) - .eq(ThirdMchFzRelationEntity::getReceiverType, relation.getReceiverType()) - .eq(ThirdMchFzRelationEntity::getReceiverAccount, relation.getReceiverAccount()) - .eq(ThirdMchFzRelationEntity::getRelationScene, DEFAULT_SCENE) - .eq(ThirdMchFzRelationEntity::getDelFlag, "0")); - if (existing == null) { - throw new IllegalStateException("并发创建平台分佣关系后未找到关系事实"); - } - restoreCurrentRelation(existing); - } - } else { - restoreCurrentRelation(existing); - } - relationMapper.update(null, new LambdaUpdateWrapper() - .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) - .eq(ThirdMchFzRelationEntity::getPaySettingId, setting.getId()) - .eq(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode()) - .ne(ThirdMchFzRelationEntity::getId, existing.getId()) - .eq(ThirdMchFzRelationEntity::getDelFlag, "0") - .set(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.NO.getCode()) - .set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode())); - if (!ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(existing.getStatus())) { - taskEnqueuer.enqueueRelationBind(tenantId, existing.getId(), LocalDateTime.now(), RELATION_TASK_PRIORITY); - } - } + List currentSettingIds = merchant.getPaySettingList() == null ? List.of() + : merchant.getPaySettingList().stream().filter(this::isEligible).map(MchPaySettingVo::getId).toList(); // 配置被禁用、删除或改成其他平台账号时,旧关系只停止接新单,不删除渠道事实。 LambdaUpdateWrapper stale = new LambdaUpdateWrapper() .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) @@ -164,7 +500,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService relationMapper.update(null, stale); } - /** 把复用的历史同身份关系恢复为当前默认关系;已确认绑定的关系不得重新降级为待绑定。 */ + /** + * 把复用的历史同身份关系恢复为可用状态;是否设为默认由调用场景单独决定。 + */ private void restoreCurrentRelation(ThirdMchFzRelationEntity relation) { String restoredStatus = relation.getBoundTime() != null ? ThirdMchFzRelationStatusEnum.BOUND.getCode() @@ -173,10 +511,8 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService : ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode(); relationMapper.update(null, new LambdaUpdateWrapper() .eq(ThirdMchFzRelationEntity::getId, relation.getId()) - .set(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode()) .set(ThirdMchFzRelationEntity::getStatus, restoredStatus) .set(ThirdMchFzRelationEntity::getBindError, null)); - relation.setIsDefault(YesNoEnum.YES.getCode()); relation.setStatus(restoredStatus); } @@ -184,87 +520,81 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService * 校验支付配置当前默认关系已明确绑定。 * * @param setting 商户支付配置 - * @return 已绑定关系;无抽佣比例时为空 + * @return 当前支付必须使用的默认且已绑定关系 */ @Override @Transactional(rollbackFor = Exception.class) public ThirdMchFzRelationEntity requireBound(MchPaySettingVo setting) { - validateFzRatio(setting); if (!isEligible(setting)) { - return null; + throw new IllegalStateException("商户支付配置未启用或不支持当前渠道"); } Long tenantId = setting.getTenantId() == null ? TenantContextHolder.getTenantId() : setting.getTenantId(); - ThirdMchFzRelationEntity expected = buildRelation(tenantId, setting.getMchId(), setting); ThirdMchFzRelationEntity relation = relationMapper.selectOne(new LambdaQueryWrapper() .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) .eq(ThirdMchFzRelationEntity::getMchId, setting.getMchId()) .eq(ThirdMchFzRelationEntity::getPaySettingId, setting.getId()) - .eq(ThirdMchFzRelationEntity::getPlatformType, expected.getPlatformType()) - .eq(ThirdMchFzRelationEntity::getChannelAppId, expected.getChannelAppId()) - .eq(ThirdMchFzRelationEntity::getChannelSubMchId, expected.getChannelSubMchId()) - .eq(ThirdMchFzRelationEntity::getReceiverType, expected.getReceiverType()) - .eq(ThirdMchFzRelationEntity::getReceiverAccount, expected.getReceiverAccount()) + .eq(ThirdMchFzRelationEntity::getPlatformType, setting.getPlatformType()) .eq(ThirdMchFzRelationEntity::getRelationScene, DEFAULT_SCENE) .eq(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode()) - .eq(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BOUND.getCode()) - .eq(ThirdMchFzRelationEntity::getDelFlag, "0")); + .eq(ThirdMchFzRelationEntity::getDelFlag, "0") + .last("LIMIT 1")); if (relation == null) { - ThirdMchFzRelationEntity pending = relationMapper.selectOne(new LambdaQueryWrapper() - .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) - .eq(ThirdMchFzRelationEntity::getMchId, setting.getMchId()) - .eq(ThirdMchFzRelationEntity::getPaySettingId, setting.getId()) - .eq(ThirdMchFzRelationEntity::getPlatformType, expected.getPlatformType()) - .eq(ThirdMchFzRelationEntity::getChannelAppId, expected.getChannelAppId()) - .eq(ThirdMchFzRelationEntity::getChannelSubMchId, expected.getChannelSubMchId()) - .eq(ThirdMchFzRelationEntity::getReceiverType, expected.getReceiverType()) - .eq(ThirdMchFzRelationEntity::getReceiverAccount, expected.getReceiverAccount()) - .eq(ThirdMchFzRelationEntity::getRelationScene, DEFAULT_SCENE) - .eq(ThirdMchFzRelationEntity::getDelFlag, "0")); - if (pending == null) { - try { - relationMapper.insert(expected); - pending = expected; - } catch (DuplicateKeyException ignored) { - pending = relationMapper.selectOne(new LambdaQueryWrapper() - .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) - .eq(ThirdMchFzRelationEntity::getMchId, setting.getMchId()) - .eq(ThirdMchFzRelationEntity::getPaySettingId, setting.getId()) - .eq(ThirdMchFzRelationEntity::getPlatformType, expected.getPlatformType()) - .eq(ThirdMchFzRelationEntity::getChannelAppId, expected.getChannelAppId()) - .eq(ThirdMchFzRelationEntity::getChannelSubMchId, expected.getChannelSubMchId()) - .eq(ThirdMchFzRelationEntity::getReceiverType, expected.getReceiverType()) - .eq(ThirdMchFzRelationEntity::getReceiverAccount, expected.getReceiverAccount()) - .eq(ThirdMchFzRelationEntity::getRelationScene, DEFAULT_SCENE) - .eq(ThirdMchFzRelationEntity::getDelFlag, "0")); - } - } - if (pending != null) { - boolean restored = !YesNoEnum.YES.getCode().equals(pending.getIsDefault()) - || ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode().equals(pending.getStatus()); - if (restored) { - // 钱包本地事务与跨服务同步之间发生回滚时,按当前配置精确恢复同一渠道身份,避免永久拒付。 - restoreCurrentRelation(pending); - relationMapper.update(null, new LambdaUpdateWrapper() - .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) - .eq(ThirdMchFzRelationEntity::getPaySettingId, setting.getId()) - .eq(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode()) - .ne(ThirdMchFzRelationEntity::getId, pending.getId()) - .eq(ThirdMchFzRelationEntity::getDelFlag, "0") - .set(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.NO.getCode()) - .set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode())); - } - if (ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(pending.getStatus())) { - return pending; - } - if (restored) { - taskEnqueuer.enqueueRelationBind(tenantId, pending.getId(), LocalDateTime.now(), RELATION_TASK_PRIORITY); - } else { - taskEnqueuer.ensureRelationBind(tenantId, pending.getId(), LocalDateTime.now(), RELATION_TASK_PRIORITY); - } - } - throw new IllegalStateException("支付平台分佣关系尚未绑定,请稍后重试"); + throw new IllegalStateException("商户未配置默认支付分账关系"); } - return relation; + return validatePaymentRelation(relation, setting, tenantId); + } + + /** + * {@inheritDoc} + */ + @Override + public MchPaySettingVo createPaymentSettingSnapshot(MchPaySettingVo setting, ThirdMchFzRelationEntity relation) { + if (setting == null || relation == null || !Objects.equals(setting.getId(), relation.getPaySettingId()) + || !Objects.equals(setting.getMchId(), relation.getMchId()) + || !Objects.equals(setting.getPlatformType(), relation.getPlatformType())) { + throw new IllegalArgumentException("支付配置与分账关系不一致"); + } + MchPaySettingVo snapshot = BeanUtil.toBean(setting, MchPaySettingVo.class); + if (WECHAT_TYPE.equals(setting.getPlatformType())) { + MchPaySettingEntity.WechatPaySetting channel = new MchPaySettingEntity.WechatPaySetting(); + channel.setSubMchid(relation.getChannelSubMchId()); + snapshot.setSettingJson(JSONUtil.toJsonStr(channel)); + } else if (ALIPAY_TYPE.equals(setting.getPlatformType())) { + MchPaySettingEntity.AliPaySetting channel = new MchPaySettingEntity.AliPaySetting(); + channel.setSubMchid(relation.getChannelSubMchId()); + channel.setAccountKey(relation.getAccountKey()); + channel.setAppAuthToken(relation.getAppAuthToken()); + snapshot.setSettingJson(JSONUtil.toJsonStr(channel)); + } else { + throw new IllegalArgumentException("不支持的支付平台关系"); + } + return snapshot; + } + + /** + * 校验内部支付请求携带的关系主键,确保底层渠道服务不能使用其他商户或历史关系创建新支付。 + * + * @param relationId 内部支付请求中的关系主键 + * @param setting 当前商户支付配置 + * @return 可用于新支付的平台抽佣关系 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public ThirdMchFzRelationEntity requirePaymentRelation(Long relationId, MchPaySettingVo setting) { + if (relationId == null || !isEligible(setting)) { + throw new IllegalStateException("支付平台分佣关系不存在或支付配置不可用"); + } + Long tenantId = setting.getTenantId() == null ? TenantContextHolder.getTenantId() : setting.getTenantId(); + ThirdMchFzRelationEntity relation = relationMapper.selectOne(new LambdaQueryWrapper() + .eq(ThirdMchFzRelationEntity::getId, relationId) + .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) + .eq(ThirdMchFzRelationEntity::getMchId, setting.getMchId()) + .eq(ThirdMchFzRelationEntity::getPaySettingId, setting.getId()) + .eq(ThirdMchFzRelationEntity::getPlatformType, setting.getPlatformType()) + .eq(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode()) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0") + .last("LIMIT 1")); + return validatePaymentRelation(relation, setting, tenantId); } /** @@ -287,8 +617,7 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService @Override public boolean bind(Long relationId) { ThirdMchFzRelationEntity relation = relationMapper.selectById(relationId); - if (relation == null || !"0".equals(relation.getDelFlag()) - || ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode().equals(relation.getStatus())) { + if (relation == null || !"0".equals(relation.getDelFlag()) || ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode().equals(relation.getStatus())) { return true; } if (ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(relation.getStatus())) { @@ -322,98 +651,287 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService } } - /** 构建关系事实。 */ - private ThirdMchFzRelationEntity buildRelation(Long tenantId, MchManageVo merchant, MchPaySettingVo setting) { - return buildRelation(tenantId, merchant.getId(), setting); + /** + * 根据管理端关系参数构造渠道身份快照;平台应用ID由服务端账号配置解析。 + */ + private ThirdMchFzRelationEntity buildManageRelation(Long tenantId, ThirdMchFzRelationSaveDto dto, + MchPaySettingVo setting) { + ThirdMchFzRelationEntity result = buildChannelRelation(tenantId, dto, setting); + result.setReceiverType(StrUtil.trim(dto.getReceiverType())); + result.setReceiverAccount(StrUtil.trim(dto.getReceiverAccount())); + result.setReceiverName(StrUtil.trim(dto.getReceiverName())); + result.setFzRatio(dto.getFzRatio()); + if (WECHAT_TYPE.equals(setting.getPlatformType())) { + result.setChannelRelationType(StrUtil.blankToDefault(dto.getChannelRelationType(), ReceiverRelationType.SERVICE_PROVIDER.name())); + } + result.setIsDefault(YesNoEnum.NO.getCode()); + result.setStatus(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode()); + result.setOutRequestNo(newRelationRequestNo(setting.getPlatformType(), result.getId())); + result.setBindReq(buildBindRequestSnapshot(result)); + return result; } - /** 按支付配置解析渠道关系身份和平台默认接收方。 */ - private ThirdMchFzRelationEntity buildRelation(Long tenantId, Long mchId, MchPaySettingVo setting) { - if (tenantId == null || mchId == null || setting == null || setting.getId() == null) { + /** + * 解析关系实际使用的渠道应用ID。支付宝应用密钥属于平台统一配置,关系表只保存渠道 AppID 快照。 + * + * @param setting 当前支付配置 + * @return 微信平台 AppID 或支付宝平台 AppID + */ + private String resolveChannelAppId(MchPaySettingVo setting) { + return WECHAT_TYPE.equals(setting.getPlatformType()) + ? requireWechatConfig().getAppPlatformAppId() : requireAliPayConfig().getAppId(); + } + + /** + * 生成一次关系绑定版本的稳定请求号;该值落库后由所有 Quartz 重试复用。 + * + *

关系主键和雪花号使用36进制编码,避免把两个19位十进制数字直接拼接后超过支付宝 + * {@code out_request_no} 的32字符限制。生成值只包含字母、数字和下划线,适合支付宝及微信 + * 共同保存;历史关系已经落库的请求号不会在重试时重新生成,以保持外部幂等语义。

+ * + * @param platformType 支付平台持久化编码 + * @param relationId 关系主键 + * @return 不超过支付宝渠道长度限制的绑定幂等号 + */ + private String newRelationRequestNo(String platformType, Long relationId) { + String prefix = ALIPAY_TYPE.equals(platformType) ? "ar_" : "wr_"; + String relationPart = relationId == null ? "0" : Long.toUnsignedString(relationId, 36); + String requestPart = Long.toUnsignedString(IdWorker.getId(), 36); + String requestNo = prefix + relationPart + "_" + requestPart; + // 当前两个无符号Long的36进制表示最多各13位,正常情况下总长度最多30位;保留兜底保证渠道约束。 + if (requestNo.length() > ALIPAY_OUT_REQUEST_NO_MAX_LENGTH) { + requestNo = prefix + requestPart; + } + return requestNo; + } + + /** + * 构造可持久化的脱敏绑定请求快照,不记录完整接收账号或授权令牌。 + * + * @param relation 将由 Worker 提交渠道的关系版本 + * @return 脱敏 JSON 快照 + */ + private String buildBindRequestSnapshot(ThirdMchFzRelationEntity relation) { + return JSONUtil.createObj() + .set("platform", ALIPAY_TYPE.equals(relation.getPlatformType()) ? "ALIPAY" : "WECHAT") + .set("receiverType", relation.getReceiverType()) + .set("receiverAccount", mask(relation.getReceiverAccount())) + .set("receiverName", relation.getReceiverName()) + .set("channelSubMchId", relation.getChannelSubMchId()) + .set("channelRelationType", relation.getChannelRelationType()) + .set("accountKey", relation.getAccountKey()) + .set("appAuthTokenConfigured", StrUtil.isNotBlank(relation.getAppAuthToken())).toString(); + } + + /** + * 管理端接收方类型、账号及微信关系类型校验。 + */ + private void validateManageReceiver(ThirdMchFzRelationSaveDto dto, MchPaySettingVo setting) { + String receiverType = StrUtil.trim(dto.getReceiverType()); + String account = StrUtil.trim(dto.getReceiverAccount()); + if (StrUtil.isBlank(receiverType) || StrUtil.isBlank(account)) { + throw new IllegalArgumentException("接收方类型和标识不能为空"); + } + if (StrUtil.isBlank(dto.getChannelSubMchId())) { + throw new IllegalArgumentException(WECHAT_TYPE.equals(setting.getPlatformType()) + ? "微信子商户号不能为空" : "支付宝直付通二级商户标识不能为空"); + } + if (ALIPAY_TYPE.equals(setting.getPlatformType()) && !ALIPAY_RECEIVER_TYPES.contains(receiverType)) { + throw new IllegalArgumentException("支付宝接收方只能是userId、loginName、openId或cardAliasNo"); + } + if (WECHAT_TYPE.equals(setting.getPlatformType()) && !WECHAT_RECEIVER_TYPES.contains(receiverType)) { + throw new IllegalArgumentException("微信接收方类型不受支持"); + } + if (WECHAT_TYPE.equals(setting.getPlatformType()) && StrUtil.isBlank(dto.getChannelRelationType())) { + throw new IllegalArgumentException("微信分账关系类型不能为空"); + } + if (WECHAT_TYPE.equals(setting.getPlatformType())) { + try { + ReceiverRelationType.valueOf(dto.getChannelRelationType()); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("微信分账关系类型不受支持"); + } + } + } + + /** + * 校验关系渠道 AppID 仍与平台统一配置一致;二级商户和授权上下文本身以关系事实为准。 + */ + private boolean matchesCurrentSetting(ThirdMchFzRelationEntity relation, MchPaySettingVo setting) { + if (!Objects.equals(relation.getPlatformType(), setting.getPlatformType())) { + return false; + } + if (WECHAT_TYPE.equals(setting.getPlatformType())) { + return Objects.equals(relation.getChannelAppId(), requireWechatConfig().getAppPlatformAppId()); + } + return Objects.equals(relation.getChannelAppId(), requireAliPayConfig().getAppId()); + } + + /** + * 仅构造关系拥有的渠道应用、二级商户和支付宝授权上下文。 + */ + private ThirdMchFzRelationEntity buildChannelRelation(Long tenantId, ThirdMchFzRelationSaveDto dto, + MchPaySettingVo setting) { + if (tenantId == null || dto == null || dto.getMchId() == null || setting == null || setting.getId() == null) { throw new IllegalArgumentException("商户支付配置关系参数不完整"); } ThirdMchFzRelationEntity result = new ThirdMchFzRelationEntity(); result.setId(IdWorker.getId()); result.setTenantId(tenantId); - result.setMchId(mchId); + result.setMchId(dto.getMchId()); result.setPaySettingId(setting.getId()); result.setPlatformType(setting.getPlatformType()); result.setRelationScene(DEFAULT_SCENE); - result.setIsDefault(YesNoEnum.YES.getCode()); + // 默认关系只承载渠道接收方身份,抽佣比例必须由关系管理明确配置。 + result.setFzRatio(0D); + result.setIsDefault(YesNoEnum.NO.getCode()); result.setStatus(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode()); result.setDelFlag("0"); result.setCreateTime(LocalDateTime.now()); result.setUpdateTime(result.getCreateTime()); if (WECHAT_TYPE.equals(setting.getPlatformType())) { AppPlusPayConfig.WechatConfig config = requireWechatConfig(); - MchPaySettingEntity.WechatPaySetting paySetting = parseWechat(setting); - String receiverAccount = StrUtil.blankToDefault(config.getProfitSharingReceiverAccount(), config.getMerchantId()); - String receiverName = StrUtil.blankToDefault(config.getProfitSharingReceiverName(), "浙江资安信信息科技有限公司"); result.setChannelAppId(config.getAppPlatformAppId()); - result.setChannelSubMchId(paySetting.getSubMchid()); - result.setReceiverType(ReceiverType.MERCHANT_ID.name()); - result.setReceiverAccount(receiverAccount); - result.setReceiverName(receiverName); + result.setChannelSubMchId(StrUtil.trim(dto.getChannelSubMchId())); result.setChannelRelationType(ReceiverRelationType.SERVICE_PROVIDER.name()); - result.setOutRequestNo("wxrel_" + result.getId()); - result.setBindReq(JSONUtil.createObj().set("platform", "WECHAT").set("appid", config.getAppPlatformAppId()) - .set("subMchid", paySetting.getSubMchid()).set("receiverType", ReceiverType.MERCHANT_ID.name()) - .set("receiverAccount", mask(receiverAccount)).toString()); } else if (ALIPAY_TYPE.equals(setting.getPlatformType())) { - AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(setting); - MchPaySettingEntity.AliPaySetting paySetting = parseAliPay(setting); - String receiverType = StrUtil.blankToDefault(config.getRoyaltyTransInType(), "userId"); - String receiverAccount = StrUtil.blankToDefault(config.getRoyaltyTransIn(), ""); - if (StrUtil.isBlank(receiverAccount)) { - throw new IllegalStateException("平台支付宝分佣接收账号未配置"); - } - if (!List.of("userId", "loginName").contains(receiverType)) { - throw new IllegalStateException("支付宝分佣接收方类型只支持userId或loginName"); - } - result.setChannelAppId(config.getAppId()); - result.setChannelSubMchId(paySetting.getSubMchid()); - result.setAccountKey(paySetting.getAccountKey()); - result.setReceiverType(receiverType); - result.setReceiverAccount(receiverAccount); - result.setReceiverName(config.getRoyaltyTransInName()); - result.setOutRequestNo("alirel_" + result.getId()); - result.setBindReq(JSONUtil.createObj().set("platform", "ALIPAY").set("appId", config.getAppId()) - .set("smid", paySetting.getSubMchid()).set("receiverType", receiverType) - .set("receiverAccount", mask(receiverAccount)).set("appAuthTokenConfigured", StrUtil.isNotBlank(paySetting.getAppAuthToken())).toString()); + String accountKey = StrUtil.trim(dto.getAccountKey()); + result.setChannelAppId(requireAliPayConfig().getAppId()); + result.setChannelSubMchId(StrUtil.trim(dto.getChannelSubMchId())); + result.setAccountKey(StrUtil.isBlank(accountKey) ? null : accountKey); + String appAuthToken = StrUtil.trim(dto.getAppAuthToken()); + result.setAppAuthToken(StrUtil.isBlank(appAuthToken) ? null : appAuthToken); } else { throw new IllegalArgumentException("不支持的支付平台关系"); } return result; } - /** 判断支付配置是否需要平台分佣关系。 */ + /** + * 设定一个关系为支付配置默认关系,并将其他关系降为历史关系但不删除渠道事实。 + */ + private void setDefault(ThirdMchFzRelationEntity relation) { + relationMapper.update(null, new LambdaUpdateWrapper() + .eq(ThirdMchFzRelationEntity::getTenantId, relation.getTenantId()) + .eq(ThirdMchFzRelationEntity::getPaySettingId, relation.getPaySettingId()) + .eq(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode()) + .ne(ThirdMchFzRelationEntity::getId, relation.getId()) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0") + .set(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.NO.getCode())); + relationMapper.update(null, new LambdaUpdateWrapper() + .eq(ThirdMchFzRelationEntity::getId, relation.getId()) + .eq(ThirdMchFzRelationEntity::getTenantId, relation.getTenantId()) + .set(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode())); + relation.setIsDefault(YesNoEnum.YES.getCode()); + } + + /** + * 钱包商户查询,关系管理接口不信任前端提交的商户支付配置。 + */ + private MchManageVo requireMerchant(Long mchId) { + R response = remoteMchService.getById(mchId); + if (response == null || !response.isOk() || response.getData() == null + || !Objects.equals(response.getData().getId(), mchId)) { + throw new IllegalArgumentException("商户不存在或当前不可用"); + } + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId != null && response.getData().getTenantId() != null + && !tenantId.equals(response.getData().getTenantId())) { + throw new IllegalArgumentException("商户不属于当前租户"); + } + return response.getData(); + } + + /** + * 关系管理视图,绝不返回完整接收账号。 + */ + private ThirdMchFzRelationVo toManageVo(ThirdMchFzRelationEntity relation) { + ThirdMchFzRelationVo vo = new ThirdMchFzRelationVo(); + vo.setId(relation.getId()); + vo.setMchId(relation.getMchId()); + vo.setPaySettingId(relation.getPaySettingId()); + vo.setPlatformType(relation.getPlatformType()); + vo.setChannelSubMchId(relation.getChannelSubMchId()); + vo.setAccountKey(relation.getAccountKey()); + vo.setAppAuthTokenConfigured(StrUtil.isNotBlank(relation.getAppAuthToken())); + vo.setReceiverType(relation.getReceiverType()); + vo.setReceiverAccount(mask(relation.getReceiverAccount())); + vo.setReceiverName(relation.getReceiverName()); + vo.setFzRatio(relation.getFzRatio()); + vo.setChannelRelationType(relation.getChannelRelationType()); + vo.setStatus(relation.getStatus()); + vo.setIsDefault(relation.getIsDefault()); + vo.setBoundTime(relation.getBoundTime()); + vo.setLastExternalStatus(relation.getLastExternalStatus()); + vo.setBindError(relation.getBindError()); + vo.setLastQueryTime(relation.getLastQueryTime()); + return vo; + } + + /** + * 判断支付配置是否具备创建平台分佣关系的渠道条件;抽佣比例由关系记录决定。 + */ private boolean isEligible(MchPaySettingVo setting) { return setting != null && setting.getId() != null && YesNoEnum.YES.getCode().equals(setting.getEnable()) - && List.of(WECHAT_TYPE, ALIPAY_TYPE).contains(setting.getPlatformType()) - && setting.getFzRatio() != null && setting.getFzRatio() > 0 && setting.getFzRatio() <= 100; + && List.of(WECHAT_TYPE, ALIPAY_TYPE).contains(setting.getPlatformType()); } - /** 校验已启用微信/支付宝配置的抽佣比例;零值表示关闭抽佣,负数或大于100属于配置错误。 */ - private void validateFzRatio(MchPaySettingVo setting) { - if (setting == null || !YesNoEnum.YES.getCode().equals(setting.getEnable()) - || !List.of(WECHAT_TYPE, ALIPAY_TYPE).contains(setting.getPlatformType()) - || setting.getFzRatio() == null) { + /** + * 校验关系抽佣比例;新增关系必须启用抽佣,已有或自动关系允许零值关闭抽佣。 + */ + private void validateRelationRatio(Double ratio, boolean required) { + if (ratio == null) { + if (required) { + throw new IllegalArgumentException("平台抽佣比例不能为空"); + } return; } - if (!Double.isFinite(setting.getFzRatio()) || setting.getFzRatio() < 0 || setting.getFzRatio() > 100) { + if (!Double.isFinite(ratio) || ratio < 0 || ratio > 100) { throw new IllegalArgumentException("平台抽佣比例必须大于等于0且不超过100%"); } + // 数据库关系字段精确到0.001%,接口层先拒绝更高精度,避免落库时静默舍入导致抽佣金额与页面配置不一致。 + if (BigDecimal.valueOf(ratio).scale() > 3) { + throw new IllegalArgumentException("平台抽佣比例最多支持三位小数(0.001%)"); + } + if (required && ratio == 0) { + throw new IllegalArgumentException("平台抽佣比例必须大于0且不超过100%"); + } } - /** 微信关系绑定。 */ + /** + * 校验关系的当前渠道归属、比例与绑定状态,并在尚未绑定时确保恢复任务存在。 + */ + private ThirdMchFzRelationEntity validatePaymentRelation(ThirdMchFzRelationEntity relation, + MchPaySettingVo setting, Long tenantId) { + if (relation == null || !matchesCurrentSetting(relation, setting) + || StrUtil.hasBlank(relation.getChannelAppId(), relation.getChannelSubMchId(), + relation.getReceiverType(), relation.getReceiverAccount())) { + throw new IllegalStateException("支付平台分佣关系与当前商户渠道配置不一致"); + } + validateRelationRatio(relation.getFzRatio(), true); + if (relation.getFzRatio() <= 0) { + throw new IllegalStateException("支付平台分佣关系未启用抽佣"); + } + if (!ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(relation.getStatus())) { + taskEnqueuer.ensureRelationBind(tenantId, relation.getId(), LocalDateTime.now(), RELATION_TASK_PRIORITY); + throw new IllegalStateException("支付平台分佣关系尚未绑定,请稍后重试"); + } + return relation; + } + + /** + * 微信关系绑定。 + */ private boolean bindWechat(ThirdMchFzRelationEntity relation) { AddReceiverRequest request = new AddReceiverRequest(); request.setAppid(relation.getChannelAppId()); request.setSubMchid(relation.getChannelSubMchId()); - request.setType(ReceiverType.MERCHANT_ID); + request.setType(ReceiverType.valueOf(relation.getReceiverType())); request.setAccount(relation.getReceiverAccount()); - request.setName(StrUtil.blankToDefault(relation.getReceiverName(), "浙江资安信信息科技有限公司")); - request.setRelationType(ReceiverRelationType.SERVICE_PROVIDER); + if (StrUtil.isNotBlank(relation.getReceiverName())) { + request.setName(relation.getReceiverName()); + } + request.setRelationType(ReceiverRelationType.valueOf(relation.getChannelRelationType())); try { AddReceiverResponse response = profitsharingService.addReceiver(request); if (response == null) { @@ -436,23 +954,20 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService } } - /** 支付宝关系绑定。 */ + /** + * 支付宝关系绑定。 + */ private boolean bindAlipay(ThirdMchFzRelationEntity relation) throws Exception { MchPaySettingEntity.AliPaySetting paySetting = loadAliPaySetting(relation); - AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(paySetting); - if (!Objects.equals(relation.getChannelAppId(), config.getAppId()) - || !Objects.equals(relation.getChannelSubMchId(), paySetting.getSubMchid())) { + AppPlusPayConfig.AliPayConfig config = requireAliPayConfig(); + if (!Objects.equals(relation.getChannelAppId(), config.getAppId()) || !Objects.equals(relation.getChannelSubMchId(), paySetting.getSubMchid())) { return markFailed(relation, "CONFIG_CHANGED", "支付宝平台账号或直付通二级商户标识已变化"); } AlipayTradeRoyaltyRelationBindModel model = new AlipayTradeRoyaltyRelationBindModel(); model.setOutRequestNo(relation.getOutRequestNo()); RoyaltyEntity receiver = new RoyaltyEntity(); receiver.setType(relation.getReceiverType()); - if ("loginName".equals(relation.getReceiverType())) { - receiver.setLoginName(relation.getReceiverAccount()); - } else { - receiver.setAccount(relation.getReceiverAccount()); - } + setAlipayReceiverIdentity(receiver, relation.getReceiverType(), relation.getReceiverAccount()); receiver.setName(relation.getReceiverName()); receiver.setMemo("平台默认分佣关系"); model.setReceiverList(List.of(receiver)); @@ -486,15 +1001,17 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService return markFailed(relation, errorCode, "支付宝分账关系配置或授权无效"); } - /** 支付宝关系绑定必须同时满足网关成功和业务 result_code=SUCCESS。 */ + /** + * 支付宝关系绑定必须同时满足网关成功和业务 result_code=SUCCESS。 + */ private boolean isAlipayBindSuccess(AlipayTradeRoyaltyRelationBindResponse response) { - return response != null && response.isSuccess() - && "SUCCESS".equalsIgnoreCase(StrUtil.trim(response.getResultCode())); + return response != null && response.isSuccess() && "SUCCESS".equalsIgnoreCase(StrUtil.trim(response.getResultCode())); } - /** 查询支付宝关系绑定结果。 */ - private AlipayTradeRoyaltyRelationBatchqueryResponse queryAlipay(AppPlusPayConfig.AliPayConfig config, - String outRequestNo, String appAuthToken) throws Exception { + /** + * 查询支付宝关系绑定结果。 + */ + private AlipayTradeRoyaltyRelationBatchqueryResponse queryAlipay(AppPlusPayConfig.AliPayConfig config, String outRequestNo, String appAuthToken) throws Exception { AlipayTradeRoyaltyRelationBatchqueryModel model = new AlipayTradeRoyaltyRelationBatchqueryModel(); model.setOutRequestNo(outRequestNo); model.setPageNum(1L); @@ -507,9 +1024,10 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService return client(config).execute(request); } - /** 查询异常只表示当前无法核实,不得覆盖原绑定请求的故障语义。 */ - private AlipayTradeRoyaltyRelationBatchqueryResponse tryQueryAlipay(AppPlusPayConfig.AliPayConfig config, - String outRequestNo, String appAuthToken) { + /** + * 查询异常只表示当前无法核实,不得覆盖原绑定请求的故障语义。 + */ + private AlipayTradeRoyaltyRelationBatchqueryResponse tryQueryAlipay(AppPlusPayConfig.AliPayConfig config, String outRequestNo, String appAuthToken) { try { return queryAlipay(config, outRequestNo, appAuthToken); } catch (Exception exception) { @@ -517,7 +1035,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService } } - /** 校验查询结果确实包含当前关系接收方,不能只凭查询接口成功判定绑定成功。 */ + /** + * 校验查询结果确实包含当前关系接收方,不能只凭查询接口成功判定绑定成功。 + */ private boolean hasAlipayReceiver(AlipayTradeRoyaltyRelationBatchqueryResponse response, ThirdMchFzRelationEntity relation) { if (response == null || !response.isSuccess() @@ -526,14 +1046,42 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService return false; } return response.getReceiverList().stream().anyMatch(receiver -> { - String account = "loginName".equals(relation.getReceiverType()) - ? receiver.getLoginName() : receiver.getAccount(); + String account = getAlipayReceiverIdentity(receiver, relation.getReceiverType()); return relation.getReceiverType().equals(receiver.getType()) && relation.getReceiverAccount().equals(account); }); } - /** 微信绑定成功响应必须与本地关系请求完全对应,避免把错误接收方误记为已绑定。 */ + /** + * 按支付宝关系类型写入 SDK 对应字段,禁止把四种身份都错误塞入 account。 + */ + private void setAlipayReceiverIdentity(RoyaltyEntity receiver, String receiverType, String receiverAccount) { + switch (receiverType) { + case "userId", "cardAliasNo" -> receiver.setAccount(receiverAccount); + case "loginName" -> { + receiver.setAccount(receiverAccount); + receiver.setLoginName(receiverAccount); + } + case "openId" -> receiver.setAccountOpenId(receiverAccount); + default -> throw new IllegalArgumentException("支付宝分账接收方类型不受支持"); + } + } + + /** + * 从支付宝关系查询响应读取接收方身份,用于精确核对绑定对象。 + */ + private String getAlipayReceiverIdentity(RoyaltyEntity receiver, String receiverType) { + return switch (receiverType) { + case "userId", "cardAliasNo" -> receiver.getAccount(); + case "loginName" -> receiver.getLoginName(); + case "openId" -> receiver.getAccountOpenId(); + default -> null; + }; + } + + /** + * 微信绑定成功响应必须与本地关系请求完全对应,避免把错误接收方误记为已绑定。 + */ private boolean wechatResponseMatches(AddReceiverResponse response, ThirdMchFzRelationEntity relation) { return Objects.equals(response.getSubMchid(), relation.getChannelSubMchId()) && response.getType() != null @@ -543,28 +1091,19 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService && Objects.equals(response.getRelationType().name(), relation.getChannelRelationType()); } - /** Worker 执行时通过内部 Feign 重新读取完整支付宝配置,关系表不保存授权 token。 */ + /** + * Worker 执行时直接从关系事实恢复支付宝渠道参数,不再回读支付配置 JSON。 + */ private MchPaySettingEntity.AliPaySetting loadAliPaySetting(ThirdMchFzRelationEntity relation) { - R response = remoteMchService.getById(relation.getMchId()); - if (response == null || !response.isOk() || response.getData() == null) { - throw new RetryableRelationException("MCH_QUERY_FAILED", "读取商户支付宝支付配置失败"); - } - MchPaySettingVo setting = response.getData().getPaySettingList() == null ? null - : response.getData().getPaySettingList().stream() - .filter(item -> relation.getPaySettingId().equals(item.getId())) - .findFirst().orElse(null); - if (setting == null || !ALIPAY_TYPE.equals(setting.getPlatformType())) { - throw new IllegalStateException("支付宝支付配置已停用或删除"); - } - MchPaySettingEntity.AliPaySetting result = parseAliPay(setting); - if (!Objects.equals(StrUtil.nullToEmpty(relation.getAccountKey()), StrUtil.nullToEmpty(result.getAccountKey()))) { - throw new IllegalStateException("支付宝平台账号配置已变化"); - } + MchPaySettingEntity.AliPaySetting result = new MchPaySettingEntity.AliPaySetting(); + result.setSubMchid(relation.getChannelSubMchId()); + result.setAccountKey(relation.getAccountKey()); + result.setAppAuthToken(relation.getAppAuthToken()); return result; } /** - * 动态创建支付宝客户端,命名账号不存在时直接失败;测试实现可替换客户端以验证渠道响应边界。 + * 动态创建平台统一支付宝客户端;测试实现可替换客户端以验证渠道响应边界。 * * @param config 已解析的平台支付宝账号配置 * @return 使用指定平台账号创建的支付宝 SDK 客户端 @@ -585,27 +1124,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService return new DefaultAlipayClient(sdk); } - /** 解析微信支付配置。 */ - private MchPaySettingEntity.WechatPaySetting parseWechat(MchPaySettingVo setting) { - MchPaySettingEntity.WechatPaySetting result = StrUtil.isBlank(setting.getSettingJson()) ? null - : JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class); - if (result == null || StrUtil.isBlank(result.getSubMchid())) { - throw new IllegalStateException("微信子商户号未配置"); - } - return result; - } - - /** 解析支付宝直付通配置。 */ - private MchPaySettingEntity.AliPaySetting parseAliPay(MchPaySettingVo setting) { - MchPaySettingEntity.AliPaySetting result = StrUtil.isBlank(setting.getSettingJson()) ? null - : JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - if (result == null || StrUtil.isBlank(result.getSubMchid())) { - throw new IllegalStateException("支付宝直付通二级商户标识未配置"); - } - return result; - } - - /** 读取微信平台配置。 */ + /** + * 读取微信平台配置。 + */ private AppPlusPayConfig.WechatConfig requireWechatConfig() { AppPlusPayConfig.WechatConfig config = appPlusPayConfig.getWechatConfig(); if (config == null || StrUtil.hasBlank(config.getAppPlatformAppId(), config.getMerchantId())) { @@ -614,29 +1135,22 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService return config; } - /** 按 accountKey 读取支付宝平台配置。 */ - private AppPlusPayConfig.AliPayConfig resolveAliPayConfig(MchPaySettingVo setting) { - MchPaySettingEntity.AliPaySetting paySetting = parseAliPay(setting); - return resolveAliPayConfig(paySetting); - } - - /** 按 accountKey 读取支付宝平台配置。 */ - private AppPlusPayConfig.AliPayConfig resolveAliPayConfig(MchPaySettingEntity.AliPaySetting setting) { + /** + * 读取平台统一支付宝应用配置;分佣接收方及商户授权上下文必须从关系表读取。 + * + * @return 支付宝平台应用配置 + */ + private AppPlusPayConfig.AliPayConfig requireAliPayConfig() { AppPlusPayConfig.AliPayConfig base = appPlusPayConfig.getAliPayConfig(); if (base == null) { throw new IllegalStateException("支付宝平台配置不存在"); } - if (StrUtil.isBlank(setting == null ? null : setting.getAccountKey())) { - return base; - } - AppPlusPayConfig.AliPayConfig named = base.getAccounts() == null ? null : base.getAccounts().get(setting.getAccountKey()); - if (named == null) { - throw new IllegalStateException("支付宝平台账号不存在"); - } - return named; + return base; } - /** 保存绑定成功快照。 */ + /** + * 保存绑定成功快照。 + */ private boolean markBound(ThirdMchFzRelationEntity relation, String response, String externalStatus) { return relationMapper.update(null, new LambdaUpdateWrapper() .eq(ThirdMchFzRelationEntity::getId, relation.getId()) @@ -649,7 +1163,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService .set(ThirdMchFzRelationEntity::getBoundTime, LocalDateTime.now())) > 0; } - /** 保存确定性失败或可重试失败快照。 */ + /** + * 保存确定性失败或可重试失败快照。 + */ private boolean markFailed(ThirdMchFzRelationEntity relation, String code, String message) { relationMapper.update(null, new LambdaUpdateWrapper() .eq(ThirdMchFzRelationEntity::getId, relation.getId()) @@ -661,7 +1177,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService return false; } - /** 保存可重试错误但保持 BINDING,后续必须使用相同关系主键和请求号重试。 */ + /** + * 保存可重试错误但保持 BINDING,后续必须使用相同关系主键和请求号重试。 + */ private void markRetry(ThirdMchFzRelationEntity relation, String code, String message) { relationMapper.update(null, new LambdaUpdateWrapper() .eq(ThirdMchFzRelationEntity::getId, relation.getId()) @@ -671,7 +1189,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService .set(ThirdMchFzRelationEntity::getLastQueryTime, LocalDateTime.now())); } - /** 微信响应脱敏快照。 */ + /** + * 微信响应脱敏快照。 + */ private String wechatResponseSnapshot(AddReceiverResponse response) { return JSONUtil.createObj().set("success", true).set("subMchid", response.getSubMchid()) .set("receiverType", response.getType() == null ? null : response.getType().name()) @@ -679,7 +1199,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService .set("relationType", response.getRelationType() == null ? null : response.getRelationType().name()).toString(); } - /** 支付宝绑定响应脱敏快照。 */ + /** + * 支付宝绑定响应脱敏快照。 + */ private String alipayBindSnapshot(AlipayTradeRoyaltyRelationBindResponse response) { return JSONUtil.createObj().set("success", response != null && response.isSuccess()) .set("code", response == null ? null : response.getCode()) @@ -687,7 +1209,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService .set("resultCode", response == null ? null : response.getResultCode()).toString(); } - /** 支付宝查询响应脱敏快照。 */ + /** + * 支付宝查询响应脱敏快照。 + */ private String alipayQuerySnapshot(AlipayTradeRoyaltyRelationBatchqueryResponse response) { return JSONUtil.createObj().set("success", response != null && response.isSuccess()) .set("code", response == null ? null : response.getCode()) @@ -696,7 +1220,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService .set("receiverCount", response == null || response.getReceiverList() == null ? 0 : response.getReceiverList().size()).toString(); } - /** 脱敏账号。 */ + /** + * 脱敏账号。 + */ private String mask(String value) { if (StrUtil.isBlank(value) || value.length() <= 4) { return "****"; @@ -708,22 +1234,30 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService * 表示渠道结果不确定的异常,Worker 捕获后按指数退避保留原请求号重试。 */ private static class RetryableRelationException extends RuntimeException { - /** 任务尝试日志使用的脱敏错误码。 */ + /** + * 任务尝试日志使用的脱敏错误码。 + */ private final String code; - /** 创建无底层异常的可重试错误。 */ + /** + * 创建无底层异常的可重试错误。 + */ RetryableRelationException(String code, String message) { super(message); this.code = code; } - /** 创建保留底层异常类型但不向数据库写入敏感报文的可重试错误。 */ + /** + * 创建保留底层异常类型但不向数据库写入敏感报文的可重试错误。 + */ RetryableRelationException(String code, String message, Throwable cause) { super(message, cause); this.code = code; } - /** 获取稳定错误码。 */ + /** + * 获取稳定错误码。 + */ String getCode() { return code; } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImpl.java index f06f0f313..48ba69e52 100755 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImpl.java @@ -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 service.createPay(context, paySetting, diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java index 184070b03..f19a25e47 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java @@ -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; } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java index b75afc0ce..8e70c26c0 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java @@ -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); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java index 0187cc43c..87a1dccbd 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java @@ -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 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; diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java index 59ae18b44..9a6d0b264 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java @@ -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 子订单回调。 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java index 379dd4bb6..baffd0612 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java @@ -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 预下单把本地保存的十五分钟截止时间同步传给渠道,避免本地和微信订单生命周期分叉。 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java index 6a02dc66c..50df7458c 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java @@ -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 captor = ArgumentCaptor.forClass(ThirdMchFzRelationEntity.class); - verify(relationMapper, times(2)).insert(captor.capture()); - List 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 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 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 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 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 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 { diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java index d71761cf0..509fe4948 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java @@ -67,8 +67,7 @@ public interface RemoteUserService { * @return 含准确总数的轻量用户分页;调用方必须检查业务状态和空数据 */ @PostMapping("/user/basic-page") - R> getScopeBasicUserPage(@RequestParam("current") Long current, @RequestParam("size") Long size, - @RequestBody UserDTO userDTO); + R> getScopeBasicUserPage(@RequestParam("current") Long current, @RequestParam("size") Long size, @RequestBody UserDTO userDTO); /** * 按当前登录人的数据权限分页查询导出所需的完整用户主资料和身份资料。 diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java index 5c8dc2589..ad4e16ee2 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java @@ -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 { * *

该接口供无需返回角色、岗位、人脸和审批信息、但可能按人脸录入状态筛选的业务列表调用,避免复用管理端完整用户分页产生额外查询。

* - * @param page 分页参数 + * @param page 分页参数 * @param userDTO 用户关键字、部门树和指定用户筛选条件 * @return 包含用户总数、基础信息、部门和身份资料的轻量分页 */ @ApiCryptoAes @PostMapping("/basic-page") public R> 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 { * *

该接口供跨服务后台导出分批读取,返回账号主资料、角色岗位和身份扩展,跳过人脸及审批流程。

* - * @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,校验通过后返回异步任务供调用端轮询。 *

手机号和部门必填;姓名为空时按“手机号 + 学生家长”生成,手机号同时作为登录账号和初始密码。

* * @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,校验通过后返回异步任务供调用端轮询。 *

模板中的手机号作为登录账号,学号写入老师扩展表,老师身份单独保存为 TEACHER。

* * @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") diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java index 7996b9427..5838784da 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java @@ -342,7 +342,8 @@ public class SysUserServiceImpl extends ServiceImpl impl */ @Override public IPage getUsersWithRolePage(Page page, UserDTO userDTO, DataScope dataScope) { - if (ObjectUtil.isNotEmpty(dataScope)) { + if (dataScope != null) { + // 用户查询只按所属部门做数据权限过滤,不能再用用户创建人限制可见范围。 dataScope.setScopeUserName(null); } IPage list = baseMapper.getUserVosPage(page, userDTO, dataScope); @@ -378,8 +379,8 @@ public class SysUserServiceImpl extends ServiceImpl impl */ @Override public IPage getBasicUsersPage(Page page, UserDTO userDTO, DataScope dataScope) { - if (ObjectUtil.isNotEmpty(dataScope)) { - // 用户查询只按所属部门做数据权限过滤,不能再用用户创建人限制可见范围。 + if (dataScope != null) { + // DataScope 继承 HashMap,不能使用集合非空判断;此查询只按所属部门过滤。 dataScope.setScopeUserName(null); } IPage list = baseMapper.getBasicUserPage(page, userDTO, dataScope); @@ -401,7 +402,7 @@ public class SysUserServiceImpl extends ServiceImpl impl */ @Override public IPage getExportUsersPage(Page page, UserDTO userDTO, DataScope dataScope) { - if (ObjectUtil.isNotEmpty(dataScope)) { + if (dataScope != null) { // 导出与列表使用相同的部门数据权限口径,不按创建人额外缩小用户范围。 dataScope.setScopeUserName(null); } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java index 7d87b4e04..13d7397ac 100755 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java @@ -56,12 +56,6 @@ public class MchPaySettingEntity extends Model { @Schema(description = "支付json配置") private String settingJson; - /** - * 平台抽佣比例,单位为百分比,取值范围为大于0且不超过100。 - */ - @Schema(description = "平台抽佣比例,单位为百分比,取值范围为(0,100]") - private Double fzRatio; - /** * 创建人 */ @@ -108,7 +102,7 @@ public class MchPaySettingEntity extends Model { static public class WechatPaySetting { /** - * 子商户号 + * 订单创建时从分账关系复制的微信子商户号;不得写回 mch_pay_setting.setting_json。 */ private String subMchid; @@ -117,17 +111,17 @@ public class MchPaySettingEntity extends Model { @Data static public class AliPaySetting { /** - * 支付宝直付通二级商户标识,保存支付宝分配的 smid/ip_role_id,不是平台应用商户号。 + * 订单创建时从分账关系复制的支付宝 smid/ip_role_id;不得写回支付配置。 */ private String subMchid; /** - * 支付宝第三方代调用授权令牌;无需代调用授权的账号允许为空。 + * 订单创建时从分账关系复制的第三方代调用授权令牌;无需代调用授权时为空。 */ private String appAuthToken; /** - * 平台支付宝命名账号键,来源于现有支付配置 JSON;为空时使用平台默认账号。 + * 订单创建时从分账关系复制的平台支付宝命名账号键;为空时使用平台默认账号。 */ private String accountKey; } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java index 56298421e..dcdbf6792 100755 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java @@ -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 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滚动发布期间旧管理端仍可能提交这些键,钱包服务必须在持久化边界统一剔除, + * 防止支付配置 JSON 与关系事实形成两个相互漂移的配置源。

+ * + * @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 支付配置实体或视图类型 @@ -209,18 +206,10 @@ public class MchPaySettingServiceImpl extends ServiceImpl - \ No newline at end of file + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java index b9f71d522..ee0513a7b 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java @@ -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 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()); } /**