Merge remote-tracking branch 'origin/master'

This commit is contained in:
zjf committed 2026-08-26 15:12:29 +08:00
commit 3ff79fbfae
167 files changed
+8464 -2481

No files matched your search

-37
View File
@@ -1,37 +0,0 @@
-- 支付宝与微信聚合订单码支付。
-- 聚合会话与实际第三方支付订单分表保存,避免改变已被多个调用方使用的 third_pay_order 契约。
-- 微信统一使用服务商平台公众号配置和各商户既有 sub_mchid,不新增逐商户公众号配置表。
CREATE TABLE `third_aggregate_pay_session`
(
`id` bigint NOT NULL COMMENT '聚合会话主键',
`aggregate_no` varchar(64) NOT NULL COMMENT '聚合支付会话唯一编号',
`access_token_hash` char(64) NOT NULL COMMENT '收银URL随机访问令牌SHA-256摘要',
`access_token_ciphertext` varchar(512) NOT NULL COMMENT '收银URL随机访问令牌Jasypt密文,仅用于幂等复用',
`biz_type` varchar(32) NOT NULL COMMENT '支付业务类型编码,首版为YXFJ_PAY对应编码',
`biz_id` varchar(128) NOT NULL COMMENT '调用方业务订单唯一标识',
`user_id` bigint NOT NULL COMMENT '付款用户ID,匿名消费机订单为-1',
`mch_id` bigint NOT NULL COMMENT '收款商户ID',
`price` int NOT NULL COMMENT '应付金额,单位为人民币分',
`status` varchar(32) NOT NULL COMMENT '聚合会话状态编码',
`selected_platform` varchar(32) DEFAULT NULL COMMENT '首个有效扫码选定的支付平台编码',
`pay_no` varchar(128) DEFAULT NULL COMMENT '绑定的third_pay_order.pay_no',
`payer_identity_hash` char(64) DEFAULT NULL COMMENT '支付宝用户ID或微信服务商公众号sp_openid的SHA-256摘要',
`expire_time` datetime NOT NULL COMMENT '聚合二维码与第三方子订单服务端失效时间',
`resolved_time` datetime DEFAULT NULL COMMENT '会话进入成功、关闭、过期或创建失败终态的时间',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
`update_time` datetime NOT NULL COMMENT '修改时间',
`del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除',
`tenant_id` bigint NOT NULL COMMENT '租户ID',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_third_aggregate_pay_no` (`tenant_id`, `aggregate_no`),
UNIQUE KEY `uk_third_aggregate_access_token` (`tenant_id`, `access_token_hash`),
UNIQUE KEY `uk_third_aggregate_child_pay_no` (`tenant_id`, `pay_no`),
KEY `idx_third_aggregate_biz` (`tenant_id`, `biz_type`, `biz_id`, `create_time`),
KEY `idx_third_aggregate_job` (`status`, `expire_time`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci
COMMENT = '支付宝与微信聚合订单码支付会话';
-153
View File
@@ -1,153 +0,0 @@
-- thirdparty 支付任务事实表与执行尝试表。
-- Quartz 是唯一周期触发入口;任务表只保存可恢复事实、租约和幂等状态。
CREATE TABLE IF NOT EXISTS `third_payment_task`
(
`id` bigint NOT NULL COMMENT '任务主键',
`tenant_id` bigint NOT NULL COMMENT '租户ID',
`task_type` varchar(48) NOT NULL COMMENT '任务类型编码',
`task_key` varchar(192) NOT NULL COMMENT '租户内稳定幂等任务键',
`pay_order_id` bigint DEFAULT NULL COMMENT 'third_pay_order主键',
`fz_order_id` bigint DEFAULT NULL COMMENT 'third_fz_order主键',
`aggregate_session_id` bigint DEFAULT NULL COMMENT 'third_aggregate_pay_session主键',
`pay_no` varchar(128) DEFAULT NULL COMMENT '平台支付单号',
`refund_no` varchar(128) DEFAULT NULL COMMENT '平台退款单号',
`fz_no` varchar(128) DEFAULT NULL COMMENT '平台分账单号',
`aggregate_no` varchar(128) DEFAULT NULL COMMENT '聚合会话单号',
`status` varchar(16) NOT NULL COMMENT '任务状态:READY/PROCESSING/RETRY/SUCCESS/FAILED/ALERT/CANCELLED',
`next_run_time` datetime NOT NULL COMMENT '下一次可执行时间',
`priority` int NOT NULL DEFAULT 0 COMMENT '任务优先级,数值越大越优先',
`lease_owner` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识',
`lease_token` varchar(64) DEFAULT NULL COMMENT '本次执行租约令牌',
`lease_expire_time` datetime DEFAULT NULL COMMENT '租约失效时间',
`attempt_count` int NOT NULL DEFAULT 0 COMMENT '累计执行次数',
`last_external_status` varchar(64) DEFAULT NULL COMMENT '最近一次渠道状态',
`last_error_code` varchar(128) DEFAULT NULL COMMENT '最近一次错误码',
`last_error_message` varchar(1024) DEFAULT NULL COMMENT '最近一次错误摘要',
`finished_time` datetime DEFAULT NULL COMMENT '任务终态时间',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
`update_time` datetime NOT NULL COMMENT '更新时间',
`del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_third_payment_task_tenant_key` (`tenant_id`, `task_key`),
KEY `idx_third_payment_task_due` (`task_type`, `status`, `next_run_time`, `priority`, `id`),
KEY `idx_third_payment_task_lease` (`task_type`, `status`, `lease_expire_time`),
KEY `idx_third_payment_task_pay_no` (`tenant_id`, `pay_no`),
KEY `idx_third_payment_task_fz_no` (`tenant_id`, `fz_no`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务事实';
CREATE TABLE IF NOT EXISTS `third_payment_task_attempt`
(
`id` bigint NOT NULL COMMENT '尝试记录主键',
`task_id` bigint NOT NULL COMMENT '任务主键',
`tenant_id` bigint NOT NULL COMMENT '租户ID',
`attempt_no` int NOT NULL COMMENT '任务内尝试序号',
`trigger_source` varchar(32) NOT NULL COMMENT '触发来源:QUARTZ/CALLBACK/SYNC/MANUAL',
`worker_id` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识',
`lease_token` varchar(64) DEFAULT NULL COMMENT '执行租约令牌',
`request_snapshot` text DEFAULT NULL COMMENT '脱敏请求快照',
`response_snapshot` text DEFAULT NULL COMMENT '脱敏响应快照',
`external_status` varchar(64) DEFAULT NULL COMMENT '渠道状态',
`error_code` varchar(128) DEFAULT NULL COMMENT '错误码',
`error_message` varchar(1024) DEFAULT NULL COMMENT '错误摘要',
`started_time` datetime NOT NULL COMMENT '开始时间',
`finished_time` datetime DEFAULT NULL COMMENT '结束时间',
PRIMARY KEY (`id`),
KEY `idx_third_payment_attempt_task` (`task_id`, `attempt_no`),
KEY `idx_third_payment_attempt_tenant_time` (`tenant_id`, `started_time`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务执行尝试';
-- Quartz 会按业务事实持续物化任务。百万级订单量下,如果业务表只有主键,每个固定触发器都会退化为全表扫描。
-- 以下索引只覆盖实际扫描谓词和租户内业务号关联;使用 information_schema 判断保证迁移可以重复执行。
SET @index_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_task_scan'),
'SELECT ''idx_third_pay_order_task_scan already exists'' AS message',
'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_task_scan` (`status`, `del_flag`, `pay_platform_type`, `update_time`, `id`)'
);
PREPARE index_stmt FROM @index_ddl;
EXECUTE index_stmt;
DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_fz_scan'),
'SELECT ''idx_third_pay_order_fz_scan already exists'' AS message',
'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_fz_scan` (`status`, `enable_fz`, `del_flag`, `pay_time`, `id`)'
);
PREPARE index_stmt FROM @index_ddl;
EXECUTE index_stmt;
DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_callback_scan'),
'SELECT ''idx_third_pay_order_callback_scan already exists'' AS message',
'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_callback_scan` (`status`, `is_biz_callback`, `del_flag`, `update_time`, `id`)'
);
PREPARE index_stmt FROM @index_ddl;
EXECUTE index_stmt;
DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_close_scan'),
'SELECT ''idx_third_pay_order_close_scan already exists'' AS message',
'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_close_scan` (`status`, `biz_type`, `del_flag`, `create_time`, `id`)'
);
PREPARE index_stmt FROM @index_ddl;
EXECUTE index_stmt;
DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_tenant_pay_no'),
'SELECT ''idx_third_pay_order_tenant_pay_no already exists'' AS message',
'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)'
);
PREPARE index_stmt FROM @index_ddl;
EXECUTE index_stmt;
DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'third_fz_order' AND index_name = 'idx_third_fz_order_task_scan'),
'SELECT ''idx_third_fz_order_task_scan already exists'' AS message',
'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_task_scan` (`status`, `del_flag`, `update_time`, `id`)'
);
PREPARE index_stmt FROM @index_ddl;
EXECUTE index_stmt;
DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'third_fz_order' AND index_name = 'idx_third_fz_order_tenant_pay_no'),
'SELECT ''idx_third_fz_order_tenant_pay_no already exists'' AS message',
'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)'
);
PREPARE index_stmt FROM @index_ddl;
EXECUTE index_stmt;
DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'third_aggregate_pay_session' AND index_name = 'idx_third_aggregate_task_scan'),
'SELECT ''idx_third_aggregate_task_scan already exists'' AS message',
'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_task_scan` (`status`, `del_flag`, `update_time`, `id`)'
);
PREPARE index_stmt FROM @index_ddl;
EXECUTE index_stmt;
DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'third_aggregate_pay_session' AND index_name = 'idx_third_aggregate_close_scan'),
'SELECT ''idx_third_aggregate_close_scan already exists'' AS message',
'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_close_scan` (`status`, `del_flag`, `create_time`, `id`)'
);
PREPARE index_stmt FROM @index_ddl;
EXECUTE index_stmt;
DEALLOCATE PREPARE index_stmt;
-- 生产执行建议:低峰期使用在线 DDL 能力建索引,完成后 ANALYZE TABLE 并用真实状态分布执行 EXPLAIN ANALYZE。
-79
View File
@@ -1,79 +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`, ADD INDEX `idx_third_payment_task_relation` (`tenant_id`, `fz_relation_id`)',
'SELECT 1'
);
PREPARE relation_task_column_stmt FROM @relation_task_column_sql;
EXECUTE relation_task_column_stmt;
DEALLOCATE PREPARE relation_task_column_stmt;
-- 支付订单固定引用创建时使用的已绑定关系,后续分账不跟随平台接收账号配置漂移。
SET @pay_relation_column_exists = (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'third_pay_order'
AND COLUMN_NAME = 'fz_relation_id'
);
SET @pay_relation_column_sql = IF(
@pay_relation_column_exists = 0,
'ALTER TABLE `third_pay_order` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''支付创建时绑定的平台分佣关系主键'' AFTER `mch_pay_setting_json`, ADD INDEX `idx_third_pay_order_fz_relation` (`tenant_id`, `fz_relation_id`)',
'SELECT 1'
);
PREPARE pay_relation_column_stmt FROM @pay_relation_column_sql;
EXECUTE pay_relation_column_stmt;
DEALLOCATE PREPARE pay_relation_column_stmt;
+362
View File
@@ -0,0 +1,362 @@
-- thirdparty 支付、聚合支付、任务补偿和商户分账关系统一迁移脚本。
-- 线上只执行本文件;脚本可重复执行,兼容全新数据库和已经执行部分旧迁移的数据库。
-- 本脚本不删除支付任务、订单、分账关系或历史配置数据;仅在迁移核对通过后清理旧配置字段。
USE `pjcloudx_thirdparty`;
-- 一、聚合订单码支付会话
CREATE TABLE IF NOT EXISTS `third_aggregate_pay_session`
(
`id` bigint NOT NULL COMMENT '聚合会话主键',
`aggregate_no` varchar(64) NOT NULL COMMENT '聚合支付会话唯一编号',
`access_token_hash` char(64) NOT NULL COMMENT '收银URL随机访问令牌SHA-256摘要',
`access_token_ciphertext` varchar(512) NOT NULL COMMENT '收银URL随机访问令牌Jasypt密文,仅用于幂等复用',
`biz_type` varchar(32) NOT NULL COMMENT '支付业务类型编码,首版为YXFJ_PAY对应编码',
`biz_id` varchar(128) NOT NULL COMMENT '调用方业务订单唯一标识',
`user_id` bigint NOT NULL COMMENT '付款用户ID,匿名消费机订单为-1',
`mch_id` bigint NOT NULL COMMENT '收款商户ID',
`price` int NOT NULL COMMENT '应付金额,单位为人民币分',
`status` varchar(32) NOT NULL COMMENT '聚合会话状态编码',
`selected_platform` varchar(32) DEFAULT NULL COMMENT '首个有效扫码选定的支付平台编码',
`pay_no` varchar(128) DEFAULT NULL COMMENT '绑定的third_pay_order.pay_no',
`payer_identity_hash` char(64) DEFAULT NULL COMMENT '支付宝用户ID或微信服务商公众号sp_openid的SHA-256摘要',
`expire_time` datetime NOT NULL COMMENT '聚合二维码与第三方子订单服务端失效时间',
`resolved_time` datetime DEFAULT NULL COMMENT '会话进入成功、关闭、过期或创建失败终态的时间',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
`update_time` datetime NOT NULL COMMENT '修改时间',
`del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除',
`tenant_id` bigint NOT NULL COMMENT '租户ID',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_third_aggregate_pay_no` (`tenant_id`, `aggregate_no`),
UNIQUE KEY `uk_third_aggregate_access_token` (`tenant_id`, `access_token_hash`),
UNIQUE KEY `uk_third_aggregate_child_pay_no` (`tenant_id`, `pay_no`),
KEY `idx_third_aggregate_biz` (`tenant_id`, `biz_type`, `biz_id`, `create_time`),
KEY `idx_third_aggregate_job` (`status`, `expire_time`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci
COMMENT = '支付宝与微信聚合订单码支付会话';
-- 二、Quartz 触发的可恢复支付任务和执行尝试
CREATE TABLE IF NOT EXISTS `third_payment_task`
(
`id` bigint NOT NULL COMMENT '任务主键',
`tenant_id` bigint NOT NULL COMMENT '租户ID',
`task_type` varchar(48) NOT NULL COMMENT '任务类型编码',
`task_key` varchar(192) NOT NULL COMMENT '租户内稳定幂等任务键',
`pay_order_id` bigint DEFAULT NULL COMMENT 'third_pay_order主键',
`fz_order_id` bigint DEFAULT NULL COMMENT 'third_fz_order主键',
`fz_relation_id` bigint DEFAULT NULL COMMENT 'third_mch_fz_relation主键',
`aggregate_session_id` bigint DEFAULT NULL COMMENT 'third_aggregate_pay_session主键',
`pay_no` varchar(128) DEFAULT NULL COMMENT '平台支付单号',
`refund_no` varchar(128) DEFAULT NULL COMMENT '平台退款单号',
`fz_no` varchar(128) DEFAULT NULL COMMENT '平台分账单号',
`aggregate_no` varchar(128) DEFAULT NULL COMMENT '聚合会话单号',
`status` varchar(16) NOT NULL COMMENT '任务状态:READY/PROCESSING/RETRY/SUCCESS/FAILED/ALERT/CANCELLED',
`next_run_time` datetime NOT NULL COMMENT '下一次可执行时间',
`priority` int NOT NULL DEFAULT 0 COMMENT '任务优先级,数值越大越优先',
`lease_owner` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识',
`lease_token` varchar(64) DEFAULT NULL COMMENT '本次执行租约令牌',
`lease_expire_time` datetime DEFAULT NULL COMMENT '租约失效时间',
`attempt_count` int NOT NULL DEFAULT 0 COMMENT '累计执行次数',
`last_external_status` varchar(64) DEFAULT NULL COMMENT '最近一次渠道状态',
`last_error_code` varchar(128) DEFAULT NULL COMMENT '最近一次错误码',
`last_error_message` varchar(1024) DEFAULT NULL COMMENT '最近一次错误摘要',
`finished_time` datetime DEFAULT NULL COMMENT '任务终态时间',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
`update_time` datetime NOT NULL COMMENT '更新时间',
`del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_third_payment_task_tenant_key` (`tenant_id`, `task_key`),
KEY `idx_third_payment_task_due` (`task_type`, `status`, `next_run_time`, `priority`, `id`),
KEY `idx_third_payment_task_lease` (`task_type`, `status`, `lease_expire_time`),
KEY `idx_third_payment_task_pay_no` (`tenant_id`, `pay_no`),
KEY `idx_third_payment_task_fz_no` (`tenant_id`, `fz_no`),
KEY `idx_third_payment_task_relation` (`tenant_id`, `fz_relation_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务事实';
CREATE TABLE IF NOT EXISTS `third_payment_task_attempt`
(
`id` bigint NOT NULL COMMENT '尝试记录主键',
`task_id` bigint NOT NULL COMMENT '任务主键',
`tenant_id` bigint NOT NULL COMMENT '租户ID',
`attempt_no` int NOT NULL COMMENT '任务内尝试序号',
`trigger_source` varchar(32) NOT NULL COMMENT '触发来源:QUARTZ/CALLBACK/SYNC/MANUAL',
`worker_id` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识',
`lease_token` varchar(64) DEFAULT NULL COMMENT '执行租约令牌',
`request_snapshot` text DEFAULT NULL COMMENT '脱敏请求快照',
`response_snapshot` text DEFAULT NULL COMMENT '脱敏响应快照',
`external_status` varchar(64) DEFAULT NULL COMMENT '渠道状态',
`error_code` varchar(128) DEFAULT NULL COMMENT '错误码',
`error_message` varchar(1024) DEFAULT NULL COMMENT '错误摘要',
`started_time` datetime NOT NULL COMMENT '开始时间',
`finished_time` datetime DEFAULT NULL COMMENT '结束时间',
PRIMARY KEY (`id`),
KEY `idx_third_payment_attempt_task` (`task_id`, `attempt_no`),
KEY `idx_third_payment_attempt_tenant_time` (`tenant_id`, `started_time`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务执行尝试';
-- 三、商户支付分账关系(微信、支付宝共用)
CREATE TABLE IF NOT EXISTS `third_mch_fz_relation`
(
`id` bigint NOT NULL COMMENT '关系主键,应用侧雪花算法生成',
`tenant_id` bigint NOT NULL COMMENT '租户ID,与mch_manage所属租户一致',
`mch_id` bigint NOT NULL COMMENT 'mch_manage商户主键',
`pay_setting_id` bigint NOT NULL COMMENT 'mch_pay_setting支付配置主键',
`platform_type` varchar(32) NOT NULL COMMENT '支付平台类型:10微信、20支付宝',
`relation_scene` varchar(64) NOT NULL COMMENT '本地关系场景,默认平台分佣为DEFAULT_PLATFORM_COMMISSION',
`channel_app_id` varchar(64) NOT NULL COMMENT '微信服务商AppID或支付宝平台AppID快照',
`channel_sub_mch_id` varchar(64) NOT NULL COMMENT '微信子商户号或历史渠道子商户标识快照',
`account_key` varchar(64) DEFAULT NULL COMMENT '支付宝命名平台账号键;默认账号及微信为空',
`app_auth_token` varchar(512) DEFAULT NULL COMMENT '支付宝第三方代调用授权令牌;仅服务端渠道调用使用',
`receiver_type` varchar(32) NOT NULL COMMENT '渠道接收方类型,如MERCHANT_ID、userId、loginName',
`receiver_account` varchar(128) NOT NULL COMMENT '平台分佣接收账号;管理端展示时必须脱敏',
`receiver_name` varchar(128) DEFAULT NULL COMMENT '平台分佣接收方名称快照',
`fz_ratio` decimal(6,3) NOT NULL DEFAULT '0.000' COMMENT '平台抽佣比例,单位百分比,范围0到100,精确到0.001%',
`channel_relation_type` varchar(64) DEFAULT NULL COMMENT '渠道关系类型;微信默认SERVICE_PROVIDER,支付宝允许为空',
`out_request_no` varchar(64) NOT NULL COMMENT '支付宝绑定稳定幂等请求号;微信作为本地稳定审计号',
`status` varchar(32) NOT NULL COMMENT 'PENDING_BIND、BINDING、BOUND、BIND_FAILED或SUPERSEDED',
`is_default` char(1) NOT NULL DEFAULT '1' COMMENT '是否为当前支付配置默认关系:0否、1是',
`bind_req` text DEFAULT NULL COMMENT '脱敏后的关系绑定请求快照',
`bind_res` text DEFAULT NULL COMMENT '渠道关系绑定或查询响应快照',
`bind_error` varchar(1000) DEFAULT NULL COMMENT '最近一次绑定错误摘要,不含凭据',
`last_external_status` varchar(128) DEFAULT NULL COMMENT '最近一次渠道结果码或外部状态',
`last_query_time` datetime DEFAULT NULL COMMENT '最近一次关系绑定或查询时间',
`bound_time` datetime DEFAULT NULL COMMENT '渠道明确确认关系已绑定的时间',
`create_by` varchar(32) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(32) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记:0正常、1删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_third_mch_fz_relation_identity`
(`tenant_id`, `pay_setting_id`, `platform_type`, `channel_app_id`, `channel_sub_mch_id`,
`receiver_type`, `receiver_account`, `relation_scene`),
KEY `idx_third_mch_fz_relation_default`
(`tenant_id`, `mch_id`, `platform_type`, `is_default`, `status`, `del_flag`),
KEY `idx_third_mch_fz_relation_task`
(`status`, `is_default`, `update_time`, `id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci
COMMENT = '商户第三方支付分账关系';
-- 四、旧环境补齐关系引用字段。每个字段独立判断,避免一个缺表/缺列阻断后续迁移。
SET @task_relation_column_exists = (
SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_payment_task' AND COLUMN_NAME = 'fz_relation_id'
);
SET @task_relation_column_sql = IF(
@task_relation_column_exists = 0,
'ALTER TABLE `third_payment_task` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''third_mch_fz_relation主键''',
'SELECT 1'
);
PREPARE task_relation_column_stmt FROM @task_relation_column_sql;
EXECUTE task_relation_column_stmt;
DEALLOCATE PREPARE task_relation_column_stmt;
SET @pay_relation_column_exists = (
SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME = 'fz_relation_id'
);
SET @pay_order_table_exists = (
SELECT COUNT(1) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order'
);
SET @pay_relation_column_sql = IF(
@pay_order_table_exists = 1 AND @pay_relation_column_exists = 0,
'ALTER TABLE `third_pay_order` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''支付创建时绑定的平台分佣关系主键''',
'SELECT 1'
);
PREPARE pay_relation_column_stmt FROM @pay_relation_column_sql;
EXECUTE pay_relation_column_stmt;
DEALLOCATE PREPARE pay_relation_column_stmt;
SET @relation_auth_column_exists = (
SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_mch_fz_relation' AND COLUMN_NAME = 'app_auth_token'
);
SET @relation_auth_column_sql = IF(
@relation_auth_column_exists = 0,
'ALTER TABLE `third_mch_fz_relation` ADD COLUMN `app_auth_token` varchar(512) DEFAULT NULL COMMENT ''支付宝第三方代调用授权令牌;仅服务端渠道调用使用''',
'SELECT 1'
);
PREPARE relation_auth_column_stmt FROM @relation_auth_column_sql;
EXECUTE relation_auth_column_stmt;
DEALLOCATE PREPARE relation_auth_column_stmt;
SET @relation_ratio_column_exists = (
SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_mch_fz_relation' AND COLUMN_NAME = 'fz_ratio'
);
SET @relation_ratio_column_sql = IF(
@relation_ratio_column_exists = 0,
'ALTER TABLE `third_mch_fz_relation` ADD COLUMN `fz_ratio` decimal(6,3) NOT NULL DEFAULT ''0.000'' COMMENT ''平台抽佣比例,单位百分比,范围0到100,精确到0.001%''',
'ALTER TABLE `third_mch_fz_relation` MODIFY COLUMN `fz_ratio` decimal(6,3) NOT NULL DEFAULT ''0.000'' COMMENT ''平台抽佣比例,单位百分比,范围0到100,精确到0.001%'''
);
PREPARE relation_ratio_column_stmt FROM @relation_ratio_column_sql;
EXECUTE relation_ratio_column_stmt;
DEALLOCATE PREPARE relation_ratio_column_stmt;
-- 五、补齐任务和业务扫描索引。索引已存在时不重复创建。
SET @index_ddl = IF(EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_payment_task' AND INDEX_NAME = 'idx_third_payment_task_relation'), 'SELECT 1', 'ALTER TABLE `third_payment_task` ADD INDEX `idx_third_payment_task_relation` (`tenant_id`, `fz_relation_id`)');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
SET @pay_task_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 5 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('status', 'del_flag', 'pay_platform_type', 'update_time', 'id'));
SET @pay_fz_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 5 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('status', 'enable_fz', 'del_flag', 'pay_time', 'id'));
SET @pay_callback_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 5 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('status', 'is_biz_callback', 'del_flag', 'update_time', 'id'));
SET @pay_close_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 5 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('status', 'biz_type', 'del_flag', 'create_time', 'id'));
SET @pay_tenant_no_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 3 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('tenant_id', 'pay_no', 'del_flag'));
SET @pay_relation_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 2 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('tenant_id', 'fz_relation_id'));
SET @fz_task_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 4 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_fz_order' AND COLUMN_NAME IN ('status', 'del_flag', 'update_time', 'id'));
SET @fz_tenant_no_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 3 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_fz_order' AND COLUMN_NAME IN ('tenant_id', 'pay_no', 'del_flag'));
SET @aggregate_task_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 4 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_aggregate_pay_session' AND COLUMN_NAME IN ('status', 'del_flag', 'update_time', 'id'));
SET @aggregate_close_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 4 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_aggregate_pay_session' AND COLUMN_NAME IN ('status', 'del_flag', 'create_time', 'id'));
SET @index_ddl = IF(@pay_task_scan_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND INDEX_NAME = 'idx_third_pay_order_task_scan'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_task_scan` (`status`, `del_flag`, `pay_platform_type`, `update_time`, `id`)', 'SELECT 1');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(@pay_fz_scan_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND INDEX_NAME = 'idx_third_pay_order_fz_scan'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_fz_scan` (`status`, `enable_fz`, `del_flag`, `pay_time`, `id`)', 'SELECT 1');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(@pay_callback_scan_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND INDEX_NAME = 'idx_third_pay_order_callback_scan'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_callback_scan` (`status`, `is_biz_callback`, `del_flag`, `update_time`, `id`)', 'SELECT 1');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(@pay_close_scan_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND INDEX_NAME = 'idx_third_pay_order_close_scan'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_close_scan` (`status`, `biz_type`, `del_flag`, `create_time`, `id`)', 'SELECT 1');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(@pay_tenant_no_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND INDEX_NAME = 'idx_third_pay_order_tenant_pay_no'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)', 'SELECT 1');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(@pay_relation_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND INDEX_NAME = 'idx_third_pay_order_fz_relation'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_fz_relation` (`tenant_id`, `fz_relation_id`)', 'SELECT 1');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(@fz_task_scan_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_fz_order' AND INDEX_NAME = 'idx_third_fz_order_task_scan'), 'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_task_scan` (`status`, `del_flag`, `update_time`, `id`)', 'SELECT 1');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(@fz_tenant_no_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_fz_order' AND INDEX_NAME = 'idx_third_fz_order_tenant_pay_no'), 'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)', 'SELECT 1');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(@aggregate_task_scan_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_aggregate_pay_session' AND INDEX_NAME = 'idx_third_aggregate_task_scan'), 'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_task_scan` (`status`, `del_flag`, `update_time`, `id`)', 'SELECT 1');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(@aggregate_close_scan_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_aggregate_pay_session' AND INDEX_NAME = 'idx_third_aggregate_close_scan'), 'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_close_scan` (`status`, `del_flag`, `create_time`, `id`)', 'SELECT 1');
PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
-- 六、迁移钱包历史配置。只有关系完整、JSON合法且数据已核对时,才清理旧配置键和旧比例列。
SET @wallet_table_exists = (
SELECT COUNT(1) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'mch_pay_setting'
);
SET @legacy_ratio_column_exists = (
SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'mch_pay_setting' AND COLUMN_NAME = 'fz_ratio'
);
SET @legacy_invalid_json_count = 0;
SET @legacy_invalid_json_sql = IF(
@wallet_table_exists = 1,
'SELECT COUNT(1) INTO @legacy_invalid_json_count FROM `pjcloudx_wallet`.`mch_pay_setting` WHERE platform_type IN (''10'', ''20'') AND setting_json IS NOT NULL AND TRIM(setting_json) <> '''' AND JSON_VALID(setting_json) = 0',
'SELECT 1'
);
PREPARE legacy_invalid_json_stmt FROM @legacy_invalid_json_sql;
EXECUTE legacy_invalid_json_stmt;
DEALLOCATE PREPARE legacy_invalid_json_stmt;
SET @legacy_channel_update_sql = IF(
@wallet_table_exists = 1 AND @legacy_invalid_json_count = 0,
'UPDATE `pjcloudx_thirdparty`.`third_mch_fz_relation` r JOIN `pjcloudx_wallet`.`mch_pay_setting` s ON s.id = r.pay_setting_id AND s.mch_id = r.mch_id AND s.tenant_id = r.tenant_id SET r.channel_sub_mch_id = CASE WHEN NULLIF(TRIM(r.channel_sub_mch_id), '''') IS NULL THEN NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.subMchid''))), ''''), ''null'') ELSE r.channel_sub_mch_id END, r.account_key = CASE WHEN s.platform_type = ''20'' AND NULLIF(TRIM(r.account_key), '''') IS NULL THEN NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.accountKey''))), ''''), ''null'') ELSE r.account_key END, r.app_auth_token = CASE WHEN s.platform_type = ''20'' AND NULLIF(TRIM(r.app_auth_token), '''') IS NULL THEN NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.appAuthToken''))), ''''), ''null'') ELSE r.app_auth_token END WHERE s.platform_type IN (''10'', ''20'') AND JSON_VALID(s.setting_json) = 1 AND r.del_flag = ''0'' AND r.is_default = ''1''',
'SELECT 1'
);
PREPARE legacy_channel_update_stmt FROM @legacy_channel_update_sql;
EXECUTE legacy_channel_update_stmt;
DEALLOCATE PREPARE legacy_channel_update_stmt;
SET @legacy_ratio_update_sql = IF(
@wallet_table_exists = 1 AND @legacy_ratio_column_exists = 1,
'UPDATE `pjcloudx_thirdparty`.`third_mch_fz_relation` r JOIN `pjcloudx_wallet`.`mch_pay_setting` s ON s.id = r.pay_setting_id AND s.mch_id = r.mch_id AND s.tenant_id = r.tenant_id SET r.fz_ratio = LEAST(GREATEST(COALESCE(s.fz_ratio, 0), 0), 100) WHERE s.del_flag = ''0'' AND r.del_flag = ''0'' AND r.is_default = ''1'' AND COALESCE(s.fz_ratio, 0) > 0 AND COALESCE(r.fz_ratio, 0) = 0',
'SELECT 1'
);
PREPARE legacy_ratio_update_stmt FROM @legacy_ratio_update_sql;
EXECUTE legacy_ratio_update_stmt;
DEALLOCATE PREPARE legacy_ratio_update_stmt;
SET @legacy_channel_unmigrated_count = 0;
SET @legacy_channel_unmigrated_sql = IF(
@wallet_table_exists = 1 AND @legacy_invalid_json_count = 0,
'SELECT COUNT(1) INTO @legacy_channel_unmigrated_count FROM `pjcloudx_wallet`.`mch_pay_setting` s WHERE s.del_flag = ''0'' AND s.platform_type IN (''10'', ''20'') AND JSON_VALID(s.setting_json) = 1 AND (NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.subMchid''))), ''''), ''null'') IS NOT NULL OR NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.accountKey''))), ''''), ''null'') IS NOT NULL OR NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.appAuthToken''))), ''''), ''null'') IS NOT NULL) AND NOT EXISTS (SELECT 1 FROM `pjcloudx_thirdparty`.`third_mch_fz_relation` r WHERE r.tenant_id = s.tenant_id AND r.mch_id = s.mch_id AND r.pay_setting_id = s.id AND r.platform_type = s.platform_type AND r.is_default = ''1'' AND r.del_flag = ''0'' AND (NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.subMchid''))), ''''), ''null'') IS NULL OR r.channel_sub_mch_id = NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.subMchid''))), ''''), ''null'')) AND (s.platform_type <> ''20'' OR NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.accountKey''))), ''''), ''null'') IS NULL OR r.account_key = NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.accountKey''))), ''''), ''null'')) AND (s.platform_type <> ''20'' OR NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.appAuthToken''))), ''''), ''null'') IS NULL OR r.app_auth_token = NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.appAuthToken''))), ''''), ''null'')))',
'SELECT 1'
);
PREPARE legacy_channel_unmigrated_stmt FROM @legacy_channel_unmigrated_sql;
EXECUTE legacy_channel_unmigrated_stmt;
DEALLOCATE PREPARE legacy_channel_unmigrated_stmt;
SET @legacy_ratio_unmigrated_count = 0;
SET @legacy_ratio_unmigrated_sql = IF(
@wallet_table_exists = 1 AND @legacy_ratio_column_exists = 1,
'SELECT COUNT(1) INTO @legacy_ratio_unmigrated_count FROM `pjcloudx_wallet`.`mch_pay_setting` s WHERE COALESCE(s.fz_ratio, 0) > 0 AND s.del_flag = ''0'' AND NOT EXISTS (SELECT 1 FROM `pjcloudx_thirdparty`.`third_mch_fz_relation` r WHERE r.tenant_id = s.tenant_id AND r.mch_id = s.mch_id AND r.pay_setting_id = s.id AND r.is_default = ''1'' AND r.fz_ratio > 0 AND r.del_flag = ''0'')',
'SELECT 1'
);
PREPARE legacy_ratio_unmigrated_stmt FROM @legacy_ratio_unmigrated_sql;
EXECUTE legacy_ratio_unmigrated_stmt;
DEALLOCATE PREPARE legacy_ratio_unmigrated_stmt;
SET @legacy_json_cleanup_sql = IF(
@wallet_table_exists = 1 AND @legacy_invalid_json_count = 0 AND @legacy_channel_unmigrated_count = 0 AND @legacy_ratio_unmigrated_count = 0,
'UPDATE `pjcloudx_wallet`.`mch_pay_setting` SET setting_json = CASE WHEN JSON_LENGTH(JSON_REMOVE(setting_json, ''$.subMchid'', ''$.accountKey'', ''$.appAuthToken'')) = 0 THEN NULL ELSE JSON_REMOVE(setting_json, ''$.subMchid'', ''$.accountKey'', ''$.appAuthToken'') END WHERE platform_type IN (''10'', ''20'') AND JSON_VALID(setting_json) = 1',
'SELECT 1'
);
PREPARE legacy_json_cleanup_stmt FROM @legacy_json_cleanup_sql;
EXECUTE legacy_json_cleanup_stmt;
DEALLOCATE PREPARE legacy_json_cleanup_stmt;
SET @drop_legacy_ratio_sql = IF(
@wallet_table_exists = 1 AND @legacy_ratio_column_exists = 1 AND @legacy_invalid_json_count = 0 AND @legacy_channel_unmigrated_count = 0 AND @legacy_ratio_unmigrated_count = 0,
'ALTER TABLE `pjcloudx_wallet`.`mch_pay_setting` DROP COLUMN `fz_ratio`',
'SELECT 1'
);
PREPARE drop_legacy_ratio_stmt FROM @drop_legacy_ratio_sql;
EXECUTE drop_legacy_ratio_stmt;
DEALLOCATE PREPARE drop_legacy_ratio_stmt;
-- 七、输出迁移结果。出现BLOCKED时不要上线新代码,先处理对应数据或数据库前置问题。
SET @required_table_count = (
SELECT COUNT(DISTINCT TABLE_NAME)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('third_aggregate_pay_session', 'third_payment_task',
'third_payment_task_attempt', 'third_mch_fz_relation',
'third_pay_order', 'third_fz_order')
);
SET @required_column_count = (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND ((TABLE_NAME = 'third_mch_fz_relation' AND COLUMN_NAME IN ('app_auth_token', 'fz_ratio'))
OR (TABLE_NAME = 'third_payment_task' AND COLUMN_NAME = 'fz_relation_id')
OR (TABLE_NAME = 'third_pay_order' AND COLUMN_NAME = 'fz_relation_id'))
);
SET @skipped_scan_index_count =
(@pay_task_scan_ready = 0) + (@pay_fz_scan_ready = 0) + (@pay_callback_scan_ready = 0)
+ (@pay_close_scan_ready = 0) + (@pay_tenant_no_ready = 0) + (@pay_relation_ready = 0)
+ (@fz_task_scan_ready = 0) + (@fz_tenant_no_ready = 0)
+ (@aggregate_task_scan_ready = 0) + (@aggregate_close_scan_ready = 0);
SELECT CASE
WHEN @required_table_count <> 6 THEN 'BLOCKED_REQUIRED_THIRD_TABLE_NOT_FOUND'
WHEN @required_column_count <> 4 THEN 'BLOCKED_REQUIRED_THIRD_COLUMN_NOT_FOUND'
WHEN @skipped_scan_index_count > 0 THEN 'BLOCKED_SCAN_INDEX_SOURCE_COLUMN_NOT_FOUND'
WHEN @wallet_table_exists = 0 THEN 'BLOCKED_WALLET_TABLE_NOT_FOUND'
WHEN @legacy_invalid_json_count > 0 THEN 'BLOCKED_INVALID_SETTING_JSON'
WHEN @legacy_channel_unmigrated_count > 0 THEN 'BLOCKED_UNMIGRATED_CHANNEL_CONFIG'
WHEN @legacy_ratio_unmigrated_count > 0 THEN 'BLOCKED_UNMIGRATED_POSITIVE_RATIO'
ELSE 'SUCCESS_THIRD_PAYMENT_SCHEMA_MIGRATED'
END AS migration_status,
@legacy_invalid_json_count AS invalid_setting_json_count,
@legacy_channel_unmigrated_count AS unmigrated_channel_config_count,
@legacy_ratio_unmigrated_count AS unmigrated_positive_ratio_count,
@required_table_count AS required_third_table_count,
@required_column_count AS required_third_column_count,
@skipped_scan_index_count AS skipped_scan_index_count;
SELECT table_name, column_name, column_type
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND ((table_name = 'third_mch_fz_relation' AND column_name IN ('app_auth_token', 'fz_ratio'))
OR (table_name = 'third_payment_task' AND column_name = 'fz_relation_id')
OR (table_name = 'third_pay_order' AND column_name = 'fz_relation_id'))
ORDER BY table_name, ordinal_position;
-3
View File
@@ -164,9 +164,6 @@ CREATE TABLE mch_pay_setting
use_scope varchar(256) NOT NULL COMMENT '使用范围(直接支付,钱包充值)',
setting_json text DEFAULT NULL COMMENT '支付json配置',
fz_ratio int NOT NULL COMMENT '分账比例(最高100%即分给对方商户的%)',
create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人',
@@ -2,7 +2,6 @@ package com.bsd.pjcloud.common.data.datascope;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSON;
import cn.hutool.json.JSONUtil;
@@ -53,19 +52,23 @@ public class DataScopeInnerInterceptor implements DataScopeInterceptor {
List<Long> deptIds = dataScope.getDeptList();
// 1.无数据权限限制,则直接返回 0 条数据
if (CollUtil.isEmpty(deptIds) && StrUtil.isBlank(dataScope.getUsername())) {
boolean hasDeptScope = CollUtil.isNotEmpty(deptIds);
boolean hasUserScope = StrUtil.isNotBlank(dataScope.getUsername())
&& StrUtil.isNotBlank(dataScope.getScopeUserName());
// 1.无可用权限条件时直接返回 0 条数据,避免生成非法的 IN (),也不能因本人字段被禁用而放开查询。
if (!hasDeptScope && !hasUserScope) {
originalSql = String.format("SELECT %s FROM (%s) temp_data_scope WHERE 1 = 2",
dataScope.getFunc().getType(), originalSql);
}
// 2.如果为本人权限 + 部门权限控制
else if (StrUtil.isNotBlank(dataScope.getUsername()) && ObjectUtil.isNotEmpty(dataScope.getScopeUserName()) && CollUtil.isNotEmpty(deptIds)) {
else if (hasUserScope && hasDeptScope) {
String join = CollectionUtil.join(deptIds, ",");
originalSql = String.format("SELECT %s FROM (%s) temp_data_scope WHERE temp_data_scope.%s = '%s' OR temp_data_scope.%s IN (%s)",
dataScope.getFunc().getType(), originalSql, dataScope.getScopeUserName(), dataScope.getUsername(), dataScope.getScopeDeptName(), join);
}
// 3. 如果为本人
else if (StrUtil.isNotBlank(dataScope.getUsername()) && ObjectUtil.isNotEmpty(dataScope.getScopeUserName())) {
else if (hasUserScope) {
originalSql = String.format("SELECT %s FROM (%s) temp_data_scope WHERE temp_data_scope.%s = '%s'",
dataScope.getFunc().getType(), originalSql, dataScope.getScopeUserName(), dataScope.getUsername());
}
@@ -26,7 +26,12 @@ public enum HardAutoRefundTriggerTypeEnum {
/**
* 管理端业务人员对已到期候选订单确认后执行的逐单退款,不复用技术重试权限。
*/
EXPIRED_MANUAL("40", "后台逐单过期退款", false);
EXPIRED_MANUAL("40", "后台逐单过期退款", false),
/**
* 管理端按商户当前配置立即重扫历史订单后触发的自动退款,失败后继续进入既有自动重试状态机。
*/
EXPIRED_RESCAN("50", "后台立即重新扫描", true);
/**
* 数据库存储的稳定触发编码。
@@ -1,12 +1,31 @@
package com.bsd.pjcloud.hard.api.dto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.hard.api.entity.HardAuthEntity;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
@Data
public class HardAuthDto extends HardAuthEntity {
public class HardAuthDto extends HardAuthEntity implements BaseUserIdentityQueryInterface {
/** 身份预筛选后用于授权记录查询的用户主键集合。 */
private List<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
private LocalDate[] createTimeList;
@@ -1,12 +1,31 @@
package com.bsd.pjcloud.hard.api.dto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.hard.api.entity.HardBroadcastEntity;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
@Data
public class HardBroadcastDto extends HardBroadcastEntity {
public class HardBroadcastDto extends HardBroadcastEntity implements BaseUserIdentityQueryInterface {
/** 身份预筛选后用于广播记录查询的用户主键集合。 */
private List<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
private LocalDate[] createTimeList;
@@ -1,12 +1,31 @@
package com.bsd.pjcloud.hard.api.dto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.hard.api.entity.HardDoorFlowEntity;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
@Data
public class HardDoorFlowDto extends HardDoorFlowEntity {
public class HardDoorFlowDto extends HardDoorFlowEntity implements BaseUserIdentityQueryInterface {
/** 身份预筛选后用于开门流程查询的用户主键集合。 */
private List<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
private LocalDate[] createTimeList;
@@ -1,5 +1,8 @@
package com.bsd.pjcloud.hard.api.dto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity;
import lombok.Data;
@@ -7,12 +10,24 @@ import java.time.LocalDate;
import java.util.List;
@Data
public class HardInvoiceDto extends HardInvoiceEntity {
public class HardInvoiceDto extends HardInvoiceEntity implements BaseUserIdentityQueryInterface {
private List<Long> deptIds;
private List<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
private List<Long> merchantIds;
private String hardType;
@@ -1,5 +1,8 @@
package com.bsd.pjcloud.hard.api.dto.order;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import lombok.Data;
@@ -8,10 +11,22 @@ import java.time.LocalDateTime;
import java.util.List;
@Data
public class HardAfterSaleDto extends HardAfterSaleEntity {
public class HardAfterSaleDto extends HardAfterSaleEntity implements BaseUserIdentityQueryInterface {
private List<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
private LocalDate[] createTimeList;
private List<Long> mchIds;
@@ -1,6 +1,9 @@
package com.bsd.pjcloud.hard.api.dto.order;
import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.common.core.constant.enums.DateRangeTypeEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import lombok.Data;
@@ -9,8 +12,9 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 硬件订单后台查询条件,身份字段用于按订单用户的身份资料筛选。 */
@Data
public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeInterface {
public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface {
private DateRangeTypeEnum dateRangeType = DateRangeTypeEnum.YESTERDAY;
@@ -131,4 +135,16 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
* `apptId`或者`appt_rule_id`不为null(筛选预约订单)
*/
Boolean apptIdOrApptRuleIdNotNull = false;
/** 用户身份协议编码集合;为空表示不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展筛选条件。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展筛选条件。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态:1 已录入、0 未录入、空值表示全部。 */
private String hasFace;
}
@@ -1,18 +1,37 @@
package com.bsd.pjcloud.hard.api.dto.spu;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.hard.api.entity.spu.HardSpuRepertoryEntity;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/** 硬件商品库存更新记录查询条件,支持按上架用户身份快速筛选。 */
@Data
public class HardSpuRepertoryDto extends HardSpuRepertoryEntity {
public class HardSpuRepertoryDto extends HardSpuRepertoryEntity implements BaseUserIdentityQueryInterface {
/** 库存更新记录创建时间闭区间;数组长度为 2 时生效。 */
private LocalDateTime[] createTimeList;
/** 身份预筛选后用于库存记录查询的上架用户主键集合。 */
private List<Long> userIds;
/** 目标柜门主键;为空时不限制柜门。 */
private Long cabinetId;
/** UPMS 用户身份协议编码集合;为空表示不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
}
@@ -163,11 +163,11 @@ public class HardOrderEntity extends Model<HardOrderEntity> {
@Schema(description = "过期预约退款配置ID快照")
private Long expiredRefundConfigId;
/** 预约订单支付成功时解析出的实际餐段结束时间;为空表示无法安全确定过期资格。 */
@Schema(description = "过期预约餐段实际结束时间快照")
/** 预约下单校验通过时固化的用餐日核销有效期结束时间,固定为 apptUseDate 当天 23:59:59;不受退款开关影响。 */
@Schema(description = "过期预约用餐日核销结束时间快照")
private LocalDateTime expiredRefundUsageEndTime;
/** 实际餐段结束时间加配置延迟后的过期退款触发时间。 */
/** 用餐日核销有效期结束时间加配置延迟后的过期退款触发时间。 */
@Schema(description = "过期预约退款触发时间")
private LocalDateTime expiredRefundDeadline;
@@ -1,6 +1,9 @@
package com.bsd.pjcloud.hard.api.vo;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.hard.api.entity.HardAuthEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface;
import lombok.Data;
@@ -8,7 +11,7 @@ import lombok.Data;
import java.util.List;
@Data
public class HardAuthVo extends HardAuthEntity implements BaseHardInterface, BaseUserDeptInterface {
public class HardAuthVo extends HardAuthEntity implements BaseHardInterface, BaseUserDeptInterface, BaseUserIdentityInterface {
/**
* 设备相关
@@ -26,10 +29,22 @@ public class HardAuthVo extends HardAuthEntity implements BaseHardInterface, Bas
private String userMobile;
/** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
private String phone;
private String userAvatar;
private Long userDeptId;
private String userDeptName;
/** 用户拥有的 UPMS 身份协议编码集合。 */
private List<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
}
@@ -1,6 +1,9 @@
package com.bsd.pjcloud.hard.api.vo;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.hard.api.entity.HardBroadcastEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface;
import lombok.Data;
@@ -8,7 +11,7 @@ import lombok.Data;
import java.util.List;
@Data
public class HardBroadcastVo extends HardBroadcastEntity implements BaseHardInterface, BaseUserDeptInterface {
public class HardBroadcastVo extends HardBroadcastEntity implements BaseHardInterface, BaseUserDeptInterface, BaseUserIdentityInterface {
/**
* 设备相关
@@ -26,12 +29,24 @@ public class HardBroadcastVo extends HardBroadcastEntity implements BaseHardInte
private String userMobile;
/** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
private String phone;
private String userAvatar;
private Long userDeptId;
private String userDeptName;
/** 用户拥有的 UPMS 身份协议编码集合。 */
private List<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
/**
* 订单相关
*/
@@ -1,6 +1,9 @@
package com.bsd.pjcloud.hard.api.vo;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.hard.api.entity.HardAuthEntity;
import com.bsd.pjcloud.hard.api.entity.HardDoorFlowEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface;
@@ -9,7 +12,7 @@ import lombok.Data;
import java.util.List;
@Data
public class HardDoorFlowVo extends HardDoorFlowEntity implements BaseHardInterface, BaseUserDeptInterface {
public class HardDoorFlowVo extends HardDoorFlowEntity implements BaseHardInterface, BaseUserDeptInterface, BaseUserIdentityInterface {
/**
* 设备相关
@@ -27,12 +30,24 @@ public class HardDoorFlowVo extends HardDoorFlowEntity implements BaseHardInterf
private String userMobile;
/** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
private String phone;
private String userAvatar;
private Long userDeptId;
private String userDeptName;
/** 用户拥有的 UPMS 身份协议编码集合。 */
private List<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
private HardAuthEntity hardAuth;
@@ -1,7 +1,10 @@
package com.bsd.pjcloud.hard.api.vo;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface;
import com.bsd.pjcloud.hard.api.vo.base.BaseOrderInterface;
@@ -9,9 +12,10 @@ import com.bsd.pjcloud.wallet.api.vo.base.BaseMchInterface;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
public class HardInvoiceVo extends HardInvoiceEntity implements BaseDeptInterface, BaseMchInterface, BaseOrderInterface, BaseHardInterface, BaseUserInterface {
public class HardInvoiceVo extends HardInvoiceEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface, BaseMchInterface, BaseOrderInterface, BaseHardInterface {
private Long historyId;
@@ -72,6 +76,24 @@ public class HardInvoiceVo extends HardInvoiceEntity implements BaseDeptInterfac
private String userMobile;
/** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
private String phone;
private String userAvatar;
/** UPMS 当前用户部门主键,用于公共用户信息组件的标准别名。 */
private Long userDeptId;
/** UPMS 当前用户部门名称,用于公共用户信息组件的标准别名。 */
private String userDeptName;
/** 用户拥有的 UPMS 身份协议编码集合。 */
private List<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
}
@@ -1,15 +1,25 @@
package com.bsd.pjcloud.hard.api.vo.order;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseOrderInterface;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 硬件订单售后详情。
*
* <p>同时保留历史用户、用户部门和用户身份协议,兼容售后详情的旧用户装配逻辑及管理端身份展示。</p>
*/
@Data
public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserInterface, BaseDeptInterface, BaseOrderInterface {
public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserInterface, BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface, BaseOrderInterface {
/**
* 自动退款任务状态;为空表示当前售后仍走人工审核,非空时前端不得展示审核或取消操作。
*/
@@ -40,8 +50,26 @@ public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserInte
private String userMobile;
/** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
private String phone;
private String userAvatar;
/** UPMS 当前用户部门主键,用于公共用户信息组件的标准别名。 */
private Long userDeptId;
/** UPMS 当前用户部门名称,用于公共用户信息组件的标准别名。 */
private String userDeptName;
/** 用户拥有的 UPMS 身份协议编码集合。 */
private List<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
/**
* 部门相关
*/
@@ -11,6 +11,11 @@ import lombok.Data;
@Data
public class HardCanteenProductApptExportRecord {
/**
* 导出聚合层级,固定为 SUMMARY、MERCHANT 或 PRODUCT;仅用于一次查询后拆分三级结果,不持久化。
*/
private String recordLevel;
/** 业务发生日期,格式为 yyyy-MM-dd;线下取支付日期,预约取核销日期。 */
private String statisticsDate;
@@ -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;
}
@@ -1,6 +1,9 @@
package com.bsd.pjcloud.hard.api.vo.order;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface;
import com.bsd.pjcloud.wallet.api.vo.base.BaseMchInterface;
@@ -11,8 +14,9 @@ import lombok.Data;
import java.time.LocalTime;
import java.util.List;
/** 硬件订单管理端展示对象,包含公共用户主资料、部门及身份信息。 */
@Data
public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterface, BaseMchInterface, BaseHardInterface {
public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseMchInterface, BaseHardInterface {
/** 统计明细来源编码;线下订单为 OFFLINE_PURCHASE,预约已核销订单为 RESERVATION_VERIFIED。 */
private String saleSource;
@@ -26,8 +30,20 @@ public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterfac
private String userMobile;
/** 用户标准手机号字段,供统一身份组件展示;与历史 userMobile 字段并存。 */
private String phone;
private String userAvatar;
/** 用户身份协议编码集合;没有身份时为空集合或空值。 */
private List<String> identityTypes;
/** 学生身份扩展资料;没有学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展资料;没有老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
/**
* 部门相关
*/
@@ -1,59 +1,128 @@
package com.bsd.pjcloud.hard.api.vo.spu;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface;
import com.bsd.pjcloud.hard.api.entity.spu.HardSpuRepertoryEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface;
import com.bsd.pjcloud.wallet.api.vo.base.BaseMchInterface;
import lombok.Data;
import java.util.List;
/** 硬件商品库存更新记录展示对象,包含上架用户主资料、部门和身份信息。 */
@Data
public class HardSpuRepertoryVo extends HardSpuRepertoryEntity implements BaseMchInterface, BaseUserInterface, BaseHardInterface {
public class HardSpuRepertoryVo extends HardSpuRepertoryEntity implements BaseMchInterface, BaseUserInterface, BaseUserDeptInterface, BaseUserIdentityInterface, BaseHardInterface {
/**
* 商品相关
*/
/** 本次更新对应的商品名称。 */
private String spuName;
/** 商品分类主键。 */
private Long spuCategoryId;
/** 商品所属商户主键。 */
private Long spuMchId;
/** 商品所在硬件柜门数量。 */
private Long hardDoorNumber;
/**
* 分类相关
*/
/** 商品分类名称。 */
private String spuCategoryName;
/**
* 商户相关
*/
/** 商品所属商户主键,供公共商户展示契约读取。 */
private Long merchantId;
/** 商品所属商户名称。 */
private String merchantName;
/** 商品所属商户 Logo 地址。 */
private String logoPic;
/** 商品所属商户类型协议编码。 */
private String merchantType;
/**
* 用户相关
*/
/** 上架用户姓名,保留历史接口字段名。 */
private String username;
/** 上架用户手机号,保留历史接口字段名。 */
private String userMobile;
/** 上架用户头像地址,保留历史接口字段名。 */
private String userAvatar;
/** 用户标准手机号字段,供统一身份组件展示;与历史 userMobile 字段并存。 */
private String phone;
/** 用户当前所属部门主键,由 UPMS 用户资料回填。 */
private Long userDeptId;
/** 用户当前所属部门名称,由 UPMS 用户资料回填。 */
private String userDeptName;
/** 用户所属部门展示名称,供用户信息单元格读取。 */
private String deptName;
/** 用户拥有的 UPMS 身份协议编码集合。 */
private List<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
/** 获取 UPMS 返回的用户当前部门主键。 */
@Override
public Long getUserDeptId() {
return userDeptId;
}
/** 保存 UPMS 返回的用户当前部门主键。 */
@Override
public void setUserDeptId(Long userDeptId) {
this.userDeptId = userDeptId;
}
/** 获取 UPMS 返回的用户当前部门名称。 */
@Override
public String getUserDeptName() {
return userDeptName;
}
/** 保存 UPMS 返回的用户当前部门名称,并同步公共展示字段。 */
@Override
public void setUserDeptName(String userDeptName) {
this.userDeptName = userDeptName;
this.deptName = userDeptName;
}
/**
* 设备相关
*/
/** 执行库存更新的硬件主键。 */
private Long hardId;
/** 执行库存更新的硬件名称。 */
private String hardName;
/** 执行库存更新的硬件类型协议编码。 */
private String hardType;
/** 执行库存更新的硬件设备编号。 */
private String deviceCode;
}
@@ -10,6 +10,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardAutoRefundTaskVo;
import com.bsd.pjcloud.hard.api.vo.order.HardExpiredRefundRescanResultVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppAutoRefundTaskVo;
import com.bsd.pjcloud.hard.service.HardAfterSaleService;
import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService;
@@ -62,7 +63,7 @@ public class HardAutoRefundTaskController {
* 按当前用户商户数据范围分页查询自动退款任务。
*
* @param page 分页参数
* @param dto 筛选条件
* @param dto 筛选条件
* @return 任务分页
*/
@GetMapping("/page")
@@ -81,24 +82,40 @@ public class HardAutoRefundTaskController {
}
/**
* 查询已到期且仍可整单退款的每日菜谱预约订单;失败或重试耗尽任务不混入候选。
* 按商户详情页传入的精确商户 ID 查询已到期且仍可整单退款的每日菜谱预约订单;失败或重试耗尽任务不混入候选。
*
* @param page 分页参数
* @param dto 候选查询条件,其中商户 ID 必填
* @return 当前商户的待过期退款订单分页
*/
@GetMapping("/expired-candidates")
@Operation(summary = "查询待过期预约退款订单")
@PreAuthorize("@pms.hasPermission('hard_hardAfterSale_view')")
public R<Page<HardAutoRefundTaskVo>> expiredCandidates(@ParameterObject Page<HardAutoRefundTaskEntity> page,
@ParameterObject HardAutoRefundTaskPageDto dto) {
List<Long> scopedMchIds = listScopedMerchantIds();
if (scopedMchIds.isEmpty()) {
Page<HardAutoRefundTaskVo> empty = new Page<>(page.getCurrent(), page.getSize(), 0L);
empty.setRecords(List.of());
return R.ok(empty);
}
dto.setMchIds(scopedMchIds);
public R<Page<HardAutoRefundTaskVo>> expiredCandidates(@ParameterObject Page<HardAutoRefundTaskEntity> page, @ParameterObject HardAutoRefundTaskPageDto dto) {
validateMerchantScope(dto.getMchId());
// 详情接口只能查询一个已校验商户,禁止客户端利用内部批量字段扩大查询范围。
dto.setMchIds(null);
return R.ok(taskService.pageExpiredCandidates(page, dto));
}
/** 管理端确认后逐单执行过期预约整单退款。 */
/**
* 按商户当前配置立即重扫历史过期预约订单;开关关闭时只同步暂停任务,开关开启时执行已到期任务。
*
* @param mchId 商户详情页传入的精确商户 ID
* @return 本轮扫描、任务同步和退款执行数量
*/
@PostMapping("/expired-rescan")
@Operation(summary = "立即重新扫描过期预约退款订单")
@Idempotent(expireTime = 5, info = "正在重新扫描,请勿重复操作")
@PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')")
public R<HardExpiredRefundRescanResultVo> expiredRescan(@RequestParam Long mchId) {
validateMerchantScope(mchId);
return R.ok(taskService.rescanExpiredReserveOrders(mchId, SecurityUtils.getUser().getId()));
}
/**
* 管理端确认后逐单执行过期预约整单退款。
*/
@PostMapping("/{id}/expired-refund")
@Operation(summary = "过期预约订单退款")
@Idempotent(expireTime = 5, info = "正在退款,请勿重复操作")
@@ -168,6 +185,26 @@ public class HardAutoRefundTaskController {
}
}
/**
* 校验单个商户属于当前管理用户数据范围,供商户详情页的精确查询使用。
*
* @param mchId 详情页传入的商户 ID
*/
private void validateMerchantScope(Long mchId) {
if (mchId == null) {
throw new RuntimeException("商户不能为空");
}
MchManageDto query = MchManageDto.builder()
.ids(List.of(mchId))
.typesList(List.of(MerchantTypeEnum.CANTEEN.getType()))
.build();
R<List<MchManageVo>> response = remoteMchService.scopeList(query);
if (response == null || !response.isOk() || response.getData() == null
|| response.getData().stream().noneMatch(item -> item != null && Objects.equals(item.getId(), mchId))) {
throw new RuntimeException("无权访问当前商户");
}
}
/**
* 查询当前管理用户可访问的食堂商户 ID;远程异常拒绝请求,不能降级为全量任务。
*
@@ -8,8 +8,6 @@ import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.excel.annotation.ResponseExcel;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardCanteenUserApptQueryDto;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
@@ -18,11 +16,8 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
import com.bsd.pjcloud.hard.excel.HardCanteenProductApptExportMergeHandler;
import com.bsd.pjcloud.hard.service.HardCanteenApptTotalService;
import com.bsd.pjcloud.hard.service.HardCanteenApptTotalExportService;
import com.bsd.pjcloud.hard.service.HardCanteenTotalService;
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
@@ -55,9 +50,6 @@ import java.util.List;
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class HardCanteenApptTotalController {
/** 无可见食堂时使用的不可命中商户主键,避免空集合被 SQL 当作“不筛选”而造成越权导出。 */
private static final Long NO_VISIBLE_CANTEEN_ID = Long.MIN_VALUE;
/**
* 食堂预约统计服务,负责菜品和用户维度的预约聚合。
*/
@@ -68,11 +60,6 @@ public class HardCanteenApptTotalController {
*/
private final HardCanteenApptTotalExportService canteenApptTotalExportService;
/**
* 通用食堂统计服务,复用部门聚合以及商户、部门信息补全能力。
*/
private final HardCanteenTotalService canteenTotalService;
/**
* 商户远程服务,用于限定 App 食堂范围。
*/
@@ -106,47 +93,6 @@ public class HardCanteenApptTotalController {
/* ************************************* end app end ************************************* */
/**
* 按预约使用日期统计食堂预约部门。
*
* @param dto 预约统计筛选条件,其中 {@code apptUseDateTimeList} 表示预约使用日期范围
* @return 按食堂聚合并包含部门、用餐时间段明细的预约统计结果
*/
@Operation(summary = "食堂预约部门统计", description = "按预约使用日期统计食堂预约部门")
@ApiCryptoAes
@PostMapping("/list-by-dept")
public R<List<HardCanteenProductTotalVo>> listByDept(@RequestBody HardOrderDto dto) {
// 预约统计必须以预约使用日期为唯一时间口径,避免调用方残留支付时间条件导致交叉筛选。
dto.setPayTimeList(null);
dto.setOrderType(HardOrderTypeEnum.RESERVE.getType());
dto.setTotalIsFindChildren(true);
dto.setApptIdOrApptRuleIdNotNull(true);
PjcloudUser user = SecurityUtils.getUser();
return R.ok(canteenTotalService.listByDept(dto, user, null));
}
/**
* 分页查询后台食堂菜品预约统计。
*
* @param page 分页参数
* @param dto 预约订单筛选条件
* @return 菜品预约统计分页
*/
@Operation(summary = "食堂菜品预约统计", description = "食堂菜品预约统计")
@PostMapping("/proudct-reserv-total")
public R<Page<HardCanteenProductTotalVo>> productReservTotal(@ParameterObject Page page, @RequestBody HardOrderDto dto) {
// 查询商户
MchManageDto mchManageDto = new MchManageDto();
mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
List<MchManageVo> mchList = remoteMchService.scopeList(mchManageDto).getData();
if (ObjectUtil.isNotEmpty(mchList) && ObjectUtil.isEmpty(dto.getMchIds())) {
dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList());
}
dto.setOrderType(HardOrderTypeEnum.RESERVE.getType());
dto.setApptIdOrApptRuleIdNotNull(true);
return R.ok(canteenApptTotalService.productReservTotal(page, dto, null));
}
/**
* 分页查询用户在指定用餐日期范围内每天的预约情况。
*
@@ -228,67 +174,4 @@ public class HardCanteenApptTotalController {
return canteenApptTotalExportService.userReservationExport(query);
}
/**
* 提交菜品预约统计异步导出任务。
*
* @param query 菜品预约日期、餐段、食堂和部门筛选条件
* @return 供前端轮询的初始任务进度
*/
@Operation(summary = "菜品预约统计异步导出", description = "按日期分批生成菜品预约三级统计 Excel")
@SysLog("菜品预约统计异步导出")
@PreAuthorize("@pms.hasPermission('hard_canteenProductApptTotal_export')")
@PostMapping("/product-reserv-export-task")
public R<AsyncTaskProgressVO> submitProductReservationExport(@RequestBody HardOrderDto query) {
applyCanteenMerchantScope(query);
return R.ok(canteenApptTotalExportService.submitProductReservationExport(query), "菜品预约导出任务已提交");
}
/**
* 同步菜品预约统计导出接口,保留给已有调用方;管理端页面使用异步任务接口。
*
* @param query 菜品预约统计筛选条件
* @return 按日期餐段、商户部门和商品三级组织的自定义 Excel 行
*/
@Operation(summary = "菜品预约统计导出", description = "导出日期餐段、商户部门和商品三级菜品预约统计")
@SysLog("菜品预约统计导出")
@PreAuthorize("@pms.hasPermission('hard_canteenProductApptTotal_export')")
@ResponseExcel(name = "菜品预约统计", inMemory = true, writeHandler = HardCanteenProductApptExportMergeHandler.class)
@PostMapping("/product-reserv-export")
public List<HardCanteenProductApptExcelVo> productReservationExport(@RequestBody HardOrderDto query) {
applyCanteenMerchantScope(query);
return canteenApptTotalExportService.productReservationExport(query);
}
/**
* 为后台菜品预约导出补充当前操作者可见的食堂范围。
*
* <p>分页查询接口已有相同约束;导出任务在异步线程执行前必须冻结这份范围,避免未选择食堂时
* 直接按空 {@code mchIds} 查询到当前权限之外的商户数据。</p>
*
* @param query 菜品预约导出的请求条件;为空时保留给导出服务做参数校验
*/
private void applyCanteenMerchantScope(HardOrderDto query) {
if (query == null) {
return;
}
MchManageDto mchManageDto = new MchManageDto();
mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
R<List<MchManageVo>> scopeResponse = remoteMchService.scopeList(mchManageDto);
List<MchManageVo> mchList = scopeResponse == null ? null : scopeResponse.getData();
List<Long> visibleMerchantIds = ObjectUtil.isEmpty(mchList) ? List.of()
: mchList.stream().map(MchManageVo::getId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isEmpty(visibleMerchantIds)) {
query.setMchIds(List.of(NO_VISIBLE_CANTEEN_ID));
return;
}
if (ObjectUtil.isEmpty(query.getMchIds())) {
query.setMchIds(visibleMerchantIds);
return;
}
// 即使调用方手工携带 mchIds,也只能导出当前操作者实际可见的食堂。
List<Long> allowedMerchantIds = query.getMchIds().stream().filter(visibleMerchantIds::contains).toList();
query.setMchIds(ObjectUtil.isEmpty(allowedMerchantIds)
? List.of(NO_VISIBLE_CANTEEN_ID) : allowedMerchantIds);
}
}
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.hard.controller.order;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
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.excel.annotation.ResponseExcel;
@@ -10,7 +11,8 @@ import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenDeptTotalExcelVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
import com.bsd.pjcloud.hard.excel.HardCanteenProductApptExportMergeHandler;
import com.bsd.pjcloud.hard.service.HardCanteenTotalService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@@ -58,11 +60,25 @@ public class HardCanteenDeptTotalController {
@Operation(summary = "食堂菜品部门统计导出", description = "按日期、食堂、来源、部门和餐段导出销售统计")
@SysLog("食堂菜品部门统计导出")
@PreAuthorize("@pms.hasPermission('hard_canteenDeptTotal_export')")
@ResponseExcel(name = "食堂菜品部门统计")
@ResponseExcel(name = "食堂菜品部门统计", inMemory = true,
writeHandler = HardCanteenProductApptExportMergeHandler.class)
@PostMapping("/export-dept")
public List<HardCanteenDeptTotalExcelVo> exportDept(@RequestBody HardOrderDto dto) {
PjcloudUser user = SecurityUtils.getUser();
return canteenTotalService.exportDept(dto, user, null);
public List<HardCanteenProductApptExcelVo> exportDept(@RequestBody HardOrderDto dto) {
return canteenTotalService.exportSales(dto, null);
}
/**
* 提交食堂部门销售统计异步导出任务。
*
* @param dto 来源统计日期、来源、食堂、餐段和部门筛选
* @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
*/
@Operation(summary = "食堂菜品部门统计异步导出", description = "按自然日分批查询并流式生成部门销售统计 Excel")
@SysLog("食堂菜品部门统计异步导出")
@PreAuthorize("@pms.hasPermission('hard_canteenDeptTotal_export')")
@PostMapping("/export-dept-task")
public R<AsyncTaskProgressVO> submitDepartmentSalesExport(@RequestBody HardOrderDto dto) {
return R.ok(canteenTotalService.submitDepartmentSalesExport(dto), "食堂菜品部门统计导出任务已提交");
}
}
@@ -1,6 +1,7 @@
package com.bsd.pjcloud.hard.controller.order;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -23,7 +24,8 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByCanteenVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByClassifyVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductLikeTopVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductTotalExcelVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
import com.bsd.pjcloud.hard.excel.HardCanteenProductApptExportMergeHandler;
import com.bsd.pjcloud.hard.service.HardCanteenTotalService;
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
@@ -113,6 +115,67 @@ public class HardCanteenTotalController {
return R.ok(canteenTotalService.proudctMchQuery(page, dto));
}
/**
* 按销售发生时间分页查询 App 食堂综合销售统计。
*
* <p>线下购买按支付时间统计,预约订单按核销时间统计;返回结构与 PC 菜品销售统计一致,
* 并且只允许查询当前商户管理员可见的食堂。</p>
*
* @param page 按自然日分页的分页参数
* @param dto 统计日期、来源和餐段筛选条件
* @return 日期、餐段和来源多维销售统计分页
*/
@ApiCryptoAes
@Operation(summary = "App食堂综合销售统计", description = "线下按支付时间、预约按核销时间查询日期餐段来源统计")
@PostMapping("/app/proudct-total")
public R<Page<HardCanteenProductTotalVo>> appProductTotal(@ParameterObject Page page,
@RequestBody HardOrderDto dto) {
if (!applyAppCanteenMerchantScope(dto)) {
return R.ok(new Page<HardCanteenProductTotalVo>());
}
return R.ok(canteenTotalService.productTotal(page, dto, null));
}
/**
* 查询 App 食堂综合销售统计中指定日期、餐段和来源的部门明细。
*
* @param page 保留的分页参数,与 PC 同名接口保持调用形式一致
* @param dto 统计日期、来源、餐段和部门筛选条件
* @return 当前用户可见食堂范围内的部门销售汇总
*/
@ApiCryptoAes
@Operation(summary = "App食堂综合销售部门明细", description = "查询日期餐段来源对应的部门销售统计")
@PostMapping("/app/proudct-total-by-dept")
public R<List<HardCanteenProductTotalVo>> appProductTotalByDept(@ParameterObject Page page,
@RequestBody HardOrderDto dto) {
if (!applyAppCanteenMerchantScope(dto)) {
return R.ok(List.of());
}
return R.ok(canteenTotalService.productTotalByDept(page, dto, null));
}
/**
* 将 App 统计查询的食堂条件收敛到当前商户管理员的数据范围。
*
* @param dto 待补全食堂范围的统计请求,已有食堂条件会与可见范围取交集
* @return 是否仍存在可查询的食堂;false 表示调用方应直接返回空数据
*/
private boolean applyAppCanteenMerchantScope(HardOrderDto dto) {
PjcloudUser user = SecurityUtils.getUser();
List<Long> allowedMchIds = remoteMchService
.getSelfMchIdList(user.getId(), List.of(MerchantTypeEnum.CANTEEN)).getData();
if (ObjectUtil.isEmpty(allowedMchIds)) {
dto.setMchIds(List.of());
return false;
}
List<Long> requestedMchIds = dto.getMchIds();
List<Long> scopedMchIds = ObjectUtil.isEmpty(requestedMchIds)
? allowedMchIds
: requestedMchIds.stream().filter(allowedMchIds::contains).distinct().toList();
dto.setMchIds(scopedMchIds);
return ObjectUtil.isNotEmpty(scopedMchIds);
}
/* ************************************* start app start ************************************* */
@Operation(summary = "根据部门 食堂菜品购买统计", description = "根据部门 食堂菜品购买统计")
@PostMapping("/proudct-total-by-dept")
@@ -150,17 +213,28 @@ public class HardCanteenTotalController {
*/
@Operation(summary = "食堂菜品销售统计导出", description = "按日期、餐段和来源导出食堂菜品销售统计")
@SysLog("食堂菜品销售统计导出")
@PreAuthorize("@pms.hasPermission('hard_canteenProductTotal_export')")
@ResponseExcel(name = "食堂菜品销售统计")
@PreAuthorize("@pms.hasPermission('hard_canteenProductTotal_export') or "
+ "@pms.hasPermission('hard_canteenProductApptTotal_export')")
@ResponseExcel(name = "食堂菜品销售统计", inMemory = true,
writeHandler = HardCanteenProductApptExportMergeHandler.class)
@PostMapping("/export-product")
public List<HardCanteenProductTotalExcelVo> exportProduct(@RequestBody HardOrderDto dto) {
MchManageDto mchManageDto = new MchManageDto();
mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
List<MchManageVo> mchList = remoteMchService.scopeList(mchManageDto).getData();
if (ObjectUtil.isNotEmpty(mchList) && ObjectUtil.isEmpty(dto.getMchIds())) {
dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList());
}
return canteenTotalService.exportProduct(dto, null);
public List<HardCanteenProductApptExcelVo> exportProduct(@RequestBody HardOrderDto dto) {
return canteenTotalService.exportSales(dto, null);
}
/**
* 提交食堂菜品销售统计异步导出任务。
*
* @param dto 来源统计日期、来源、食堂、餐段和部门筛选
* @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
*/
@Operation(summary = "食堂菜品销售统计异步导出", description = "按自然日分批查询并流式生成销售统计 Excel")
@SysLog("食堂菜品销售统计异步导出")
@PreAuthorize("@pms.hasPermission('hard_canteenProductTotal_export') or "
+ "@pms.hasPermission('hard_canteenProductApptTotal_export')")
@PostMapping("/export-product-task")
public R<AsyncTaskProgressVO> submitProductSalesExport(@RequestBody HardOrderDto dto) {
return R.ok(canteenTotalService.submitProductSalesExport(dto), "食堂菜品销售统计导出任务已提交");
}
/**
@@ -2,30 +2,23 @@ package com.bsd.pjcloud.hard.controller.order;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.io.resource.ResourceUtil;
import cn.hutool.core.util.ObjectUtil;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.alibaba.excel.write.metadata.fill.FillConfig;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
import com.bsd.pjcloud.common.core.util.BeanUtils;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum;
import com.bsd.pjcloud.common.idempotent.annotation.Idempotent;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
@@ -53,7 +46,6 @@ import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateReserveOrdersVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppScanPayOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateShopOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardTakeOutDeliveryTimeOptionVO;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardOrderExcelVo;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.api.vo.order.HardOnlineCanteenStatsVo;
import com.bsd.pjcloud.hard.service.*;
@@ -74,14 +66,8 @@ import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.IOException;
import java.time.LocalDate;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 订单表
@@ -100,6 +86,9 @@ public class HardOrderController {
private final HardOrderSyncService hardOrderSyncService;
private final HardCanteenTotalService canteenTotalService;
/** 订单导出服务,负责异步任务、分批模板写入和文件中心上传。 */
private final HardOrderExportService hardOrderExportService;
private final HardOrderMapper hardOrderMapper;
private final RemoteMchService mchService;
@@ -214,47 +203,67 @@ public class HardOrderController {
return R.ok(hardwareOrderService.pagejoin(page, dto));
}
@Operation(summary = "app查询食堂订餐的所有商户统计", description = "app查询食堂订餐的所有商户统计")
/**
* 按预约用餐时间统计当前商户管理员可见食堂的预约订单数量。
*
* @param dto 预约订单状态和预约用餐时间范围;创建、支付等其他时间条件会被忽略
* @return 各食堂在预约时间范围内的订单统计
*/
@Operation(summary = "app查询食堂订餐的所有商户统计", description = "按预约用餐时间查询食堂订餐商户统计")
@GetMapping("/app/caneen-rserve-mch-total")
public R appCaneenRserveMchTotal(@ParameterObject HardOrderDto dto) {
DataScope dataScope = null;
PjcloudUser user = SecurityUtils.getUser();
normalizeAppReservationUseDateQuery(dto);
dto.setOrderType(HardOrderTypeEnum.RESERVE.getType());
dto.setTotalIsFindChildren(false);
dto.setApptIdOrApptRuleIdNotNull(true);
return R.ok(canteenTotalService.listByDept(dto, user, dataScope));
}
@Operation(summary = "app查询食堂订餐订单列表", description = "app查询食堂订餐订单列表")
/**
* 按预约用餐时间分页查询当前商户管理员可核销的食堂预约订单。
*
* @param page 分页参数
* @param dto 订单状态、食堂和预约用餐时间范围;创建、支付等其他时间条件会被忽略
* @return 按预约用餐日期倒序排列的预约订单分页
*/
@Operation(summary = "app查询食堂订餐订单列表", description = "按预约用餐时间查询食堂订餐订单列表")
@GetMapping("/app/caneen-rserve-page-join")
public R appCaneenRservePageJoin(@ParameterObject Page page, @ParameterObject HardOrderDto dto) {
normalizeAppReservationUseDateQuery(dto);
dto.setIsFindCarList(true);
dto.setIsFindApptList(true);
dto.setOrderType(HardOrderTypeEnum.RESERVE.getType());
dto.setOrderStatusCodes(List.of(HardOrderStatusEnum.PAY.getType(), HardOrderStatusEnum.CANCEL.getType()));
dto.setApptIdOrApptRuleIdNotNull(true);
DataScope dataScope = null;
// 查询商户
MchManageDto mchManageDto = new MchManageDto();
mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
List<MchManageVo> mchList = mchService.scopeList(mchManageDto).getData();
if (ObjectUtil.isEmpty(mchList)) {
List<Long> mchIdList = mchService.getSelfMchIdList(SecurityUtils.getUser().getId(), List.of(MerchantTypeEnum.CANTEEN)).getData();
if (ObjectUtil.isEmpty(mchIdList)) {
return R.ok(new Page<HardOrderVo>());
}
List<Long> allowedMchIds = mchList.stream().map(MchManageVo::getId).toList();
dto.setMchIds(ObjectUtil.isEmpty(dto.getMchIds())
? allowedMchIds
: dto.getMchIds().stream().filter(allowedMchIds::contains).toList());
dto.setMchIds(ObjectUtil.isEmpty(dto.getMchIds()) ? mchIdList : dto.getMchIds().stream().filter(mchIdList::contains).toList());
if (ObjectUtil.isEmpty(dto.getMchIds())) {
return R.ok(new Page<HardOrderVo>());
}
return R.ok(hardwareOrderService.scopePageList(page, dto, dataScope));
}
/**
* 统一商户核销页面的预约时间口径,避免历史创建时间或支付时间参数与预约日期形成交叉筛选。
*
* @param dto 当前两个核销查询接口接收的订单筛选条件,方法会原地清除非预约日期条件
*/
private void normalizeAppReservationUseDateQuery(HardOrderDto dto) {
dto.setCreateTimeList(null);
dto.setCreateTimeStr(null);
dto.setPayTimeList(null);
dto.setUpdateTimeList(null);
dto.setVerificationTimeList(null);
dto.setStatisticsDateTimeList(null);
}
@Operation(summary = "app查询食堂订单列表", description = "app查询订单列表")
@ApiCryptoAes
@GetMapping("/app/caneen-page-join")
@@ -652,77 +661,30 @@ public class HardOrderController {
return R.ok(hardwareOrderService.shipment(dto));
}
/**
* 提交订单异步分批导出任务。
*
* @param dto 订单页面当前筛选条件;orderIds 非空时仅导出勾选订单
* @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
*/
@Operation(summary = "订单记录异步导出", description = "按订单筛选条件分批查询并流式生成 Excel")
@SysLog("订单记录异步导出")
@PostMapping("/export-task")
@PreAuthorize("@pms.hasPermission('hard_hardwareOrder_export')")
public R<AsyncTaskProgressVO> submitExport(@RequestBody HardOrderDto dto) {
return R.ok(hardOrderExportService.submitExport(dto), "订单导出任务已提交");
}
/**
* 兼容仍使用旧下载方式的订单页面,同步响应内部也由独立导出服务分批生成。
*
* @param response 当前 HTTP 下载响应
* @param dto 订单页面当前筛选条件
*/
@Operation(summary = "订单记录导出", description = "兼容旧页面的同步订单导出")
@GetMapping("/export")
@PreAuthorize("@pms.hasPermission('hard_hardwareOrder_export')")
public void export(HttpServletResponse response, HardOrderDto dto) throws IOException {
DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL);
dataScope.setScopeDeptName("merchant_dept_id");
PjcloudUser user = SecurityUtils.getUser();
CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user);
dto.setMchTypeCodes(hardTypeInfo.getHardTypeList());
// 查询商户和下面的子商户
List<Long> mchDtoIds = dto.getMchIds();
if (ObjectUtil.isNotEmpty(mchDtoIds) && mchDtoIds.size() == 1) {
Long mchId = mchDtoIds.get(0);
List<MchManageVo> mchList = mchService.getByParentId(mchId).getData();
if (ObjectUtil.isNotEmpty(mchList)) {
List<Long> mchIds = mchList.stream().map(MchManageVo::getId).toList();
mchDtoIds.addAll(mchIds);
dto.setMchIds(mchDtoIds);
}
}
List<HardOrderVo> list = hardwareOrderService.scopeList(dto, DataScope.of());
List<HardOrderExcelVo> excelList = BeanUtils.toBean(list, HardOrderExcelVo.class);
try (ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).withTemplate(ResourceUtil.getStream("file/订单交易汇总.xlsx")).build()) {
WriteSheet writeSheet = EasyExcel.writerSheet().build();
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
Map<String, Object> map = new HashMap<String, Object>();
if (ObjectUtil.isNotEmpty(dto.getPayTimeList())) {
map.put("timeSpan", " 到 ");
map.put("startTime", LocalDateTimeUtil.format(dto.getPayTimeList()[0], DatePattern.NORM_DATETIME_PATTERN));
map.put("endTime", LocalDateTimeUtil.format(dto.getPayTimeList()[1], DatePattern.NORM_DATETIME_PATTERN));
}
if (ObjectUtil.isNotEmpty(excelList)) {
BigDecimal totalPriceSum = BigDecimal.ZERO;
BigDecimal extraPriceSum = BigDecimal.ZERO;
BigDecimal payPriceSum = BigDecimal.ZERO;
Long lineNumber = 1L;
for (HardOrderExcelVo v : excelList) {
v.setLineNumber(lineNumber);
lineNumber++;
if (ObjectUtil.isNotEmpty(v.getTotalPrice())) {
totalPriceSum = totalPriceSum.add(BigDecimal.valueOf(v.getTotalPrice()));
v.setTotalPrice(BigDecimal.valueOf(v.getTotalPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue());
}
if (ObjectUtil.isNotEmpty(v.getExtraPrice())) {
extraPriceSum = extraPriceSum.add(BigDecimal.valueOf(v.getExtraPrice()));
v.setExtraPrice(BigDecimal.valueOf(v.getExtraPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue());
}
if (ObjectUtil.isNotEmpty(v.getPayPrice())) {
payPriceSum = payPriceSum.add(BigDecimal.valueOf(v.getPayPrice()));
v.setPayPrice(BigDecimal.valueOf(v.getPayPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue());
}
if (ObjectUtil.isNotEmpty(v.getHourType())) {
HardHourTypeEnum hardHourTypeEnum = HardHourTypeEnum.valueOfByType(v.getHourType());
if (ObjectUtil.isNotEmpty(hardHourTypeEnum)) {
v.setHourType(hardHourTypeEnum.getDescription());
} else {
v.setHourType("");
}
}
if (ObjectUtil.isNotEmpty(v.getPayType())) {
HardPayTypeEnum hardPayTypeEnum = HardPayTypeEnum.valueOfByType(v.getPayType());
if (ObjectUtil.isNotEmpty(hardPayTypeEnum)) {
v.setPayType(hardPayTypeEnum.getDescription());
}
}
}
map.put("totalPriceSum", totalPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
map.put("extraPriceSum", extraPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
map.put("payPriceSum", payPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
}
excelWriter.fill(map, writeSheet);
excelWriter.fill(excelList, fillConfig, writeSheet);
}
public void export(HttpServletResponse response, HardOrderDto dto) {
hardOrderExportService.export(response, dto);
}
}
@@ -47,7 +47,7 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler
/** 日期分组边框线型,参考模板使用中等线宽突出每天的数据边界。 */
private static final BorderStyle DATE_GROUP_BORDER_STYLE = BorderStyle.MEDIUM;
/** 日期分组边框颜色,按预约日期顺序交替使用蓝色和橙色。 */
/** 日期分组边框颜色,按统计日期顺序交替使用蓝色和橙色。 */
private static final byte[][] DATE_GROUP_BORDER_COLORS = {
{0x5B, (byte) 0x9B, (byte) 0xD5},
{(byte) 0xED, 0x7D, 0x31}
@@ -75,13 +75,13 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler
/** 最近写出的数据行号,用于工作簿结束时收尾最后一组。 */
private int lastDataRow = -1;
/** 当前预约日期分组序号,同一天的所有餐段共用一个序号和边框颜色。 */
/** 当前统计日期分组序号,同一天的所有餐段和来源块共用一个序号和边框颜色。 */
private int dateGroupIndex = -1;
/** 当前预约日期的单元格值标识,用于判断新餐段是否仍属于同一天。 */
/** 当前统计日期的单元格值标识,用于判断新餐段或来源块是否仍属于同一天。 */
private String currentDateKey;
/** 当前导出工作表;本处理器只注册到菜品预约导出的单个工作表。 */
/** 当前导出工作表;本处理器只注册到菜品销售统计导出的单个工作表。 */
private Sheet currentSheet;
/**
@@ -203,14 +203,14 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler
}
/**
* 为当前预约日期设置整组外框:当天首个餐段首行加顶部边框,当天所有行加左右边框,末行加底部边框。
* 为当前统计日期设置整组外框:当天首个来源块首行加顶部边框,当天所有行加左右边框,末行加底部边框。
*
* <p>流式工作簿会刷出较早的数据行,分组结束时无法可靠回读整组行,因此左右边框必须随行写出;
* 新日期开始时,最近写出的上一天末行仍在流式窗口内,可补齐上一天的本色底边。</p>
*
* @param row 当前数据行
* @param holder 当前工作簿持有者,用于创建彩色边框样式
* @param groupIndex 当前预约日期分组序号,未识别到日期时为负数
* @param groupIndex 当前统计日期分组序号,未识别到日期时为负数
* @param topBorder 是否为当天首行并设置顶部边框
* @param bottomBorder 是否为当天末行并设置底部边框
*/
@@ -284,10 +284,10 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler
}
/**
* 获取日期汇总单元格的稳定值标识;同一预约日期的不同餐段会得到相同标识。
* 获取日期汇总单元格的稳定值标识;同一统计日期的不同餐段和来源块会得到相同标识。
*
* @param row 当前日期餐段汇总行
* @param columnIndex 预约日期所在列索引
* @param columnIndex 统计日期所在列索引
* @return 用于判断日期是否变化的非空值标识
*/
private String getCellValueKey(Row row, int columnIndex) {
@@ -20,6 +20,7 @@ public interface HardDoorFlowMapper extends PjcloudBaseMapper<HardDoorFlowEntity
MPJLambdaQueryWrapper<HardDoorFlowEntity> wrapper = new MPJLambdaQueryWrapper<HardDoorFlowEntity>()
.selectAll(HardDoorFlowEntity.class)
.eq(ObjectUtil.isNotEmpty(dto.getUserId()), HardDoorFlowEntity::getUserId, dto.getUserId())
.in(ObjectUtil.isNotEmpty(dto.getUserIds()), HardDoorFlowEntity::getUserId, dto.getUserIds())
.eq(ObjectUtil.isNotEmpty(dto.getBizType()), HardDoorFlowEntity::getBizType, dto.getBizType())
.eq(ObjectUtil.isNotEmpty(dto.getHardDoorId()), HardDoorFlowEntity::getHardDoorId, dto.getHardDoorId())
.eq(ObjectUtil.isNotEmpty(dto.getCabinetNo()), HardDoorFlowEntity::getCabinetNo, dto.getCabinetNo())
@@ -35,4 +36,4 @@ public interface HardDoorFlowMapper extends PjcloudBaseMapper<HardDoorFlowEntity
.orderByDesc(HardDoorFlowEntity::getId);
return selectJoinPage(page, HardDoorFlowVo.class, wrapper);
}
}
}
@@ -25,4 +25,4 @@ public interface HardInvoiceMapper extends PjcloudBaseMapper<HardInvoiceEntity>
Page<HardInvoiceVo> scopePageList(Page page, @Param("dto") HardInvoiceDto dto, @Param("dataScope") DataScope dataScope);
}
}
@@ -5,10 +5,12 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
import java.util.List;
/**
* 自动退款任务 Mapper,任务执行通过主键行锁串行化多实例竞争。
@@ -64,4 +66,12 @@ public interface HardAutoRefundTaskMapper extends PjcloudBaseMapper<HardAutoRefu
Page<HardAutoRefundTaskEntity> pageExpiredCandidates(Page<HardAutoRefundTaskEntity> page,
@Param("dto") HardAutoRefundTaskPageDto dto,
@Param("now") LocalDateTime now);
/**
* 查询指定商户仍处于已支付未核销状态且没有冲突售后的每日菜谱钱包订单,供立即重扫补建任务。
*
* @param mchId 精确商户 ID
* @return 按订单主键升序排列的可重扫订单
*/
List<HardOrderEntity> listExpiredRescanOrders(@Param("mchId") Long mchId);
}
@@ -49,8 +49,8 @@ public interface HardCarMapper extends PjcloudBaseMapper<HardCarEntity> {
/**
* 查询菜品多维统计行且不应用数据库分页。
*
* <p>同一次查询通过 ROLLUP 返回当日合计、餐段合计和餐段来源三层数据,保证交易人数在每个层级独立去重;
* 服务层按完整自然日分页,导出仅选择餐段来源层。</p>
* <p>同一次查询通过统一来源 CTE 返回当日合计、来源合计、餐段合计和餐段来源四层数据,
* 保证交易人数在每个层级独立去重;服务层按完整自然日分页。</p>
*
* @param dto 统计日期、来源、食堂、餐段和部门筛选
* @param dataScope 订单所属部门数据范围;为空时不附加范围条件
@@ -70,28 +70,15 @@ public interface HardCarMapper extends PjcloudBaseMapper<HardCarEntity> {
Page<HardCanteenProductTotalVo> productTotalByApptUseDate(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
/**
* 查询菜品销售三级导出的日期餐段汇总及来源拆分。
* 一次查询菜品销售三级导出的汇总、商户部门和商品聚合行。
*
* <p>SQL 只构造一次来源 CTE,再按记录层级分别聚合,避免三个查询重复扫描订单、购物车和退款数据。</p>
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @return 每个日期餐段的跨来源合计及各来源统计,交易人数在当前层级独立去重
* @param dataScope 订单所属部门数据范围;为空时不附加范围条件
* @return 按层级、日期、餐段、来源、商户部门和商品稳定排序的三级聚合行
*/
List<HardCanteenProductApptExportRecord> listSalesExportSummary(@Param("dto") HardOrderDto dto);
/**
* 查询菜品销售三级导出的商户部门汇总及来源拆分。
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @return 每个日期餐段下商户部门的跨来源合计及各来源统计
*/
List<HardCanteenProductApptExportRecord> listSalesExportMerchant(@Param("dto") HardOrderDto dto);
/**
* 查询菜品销售三级导出的商品汇总及来源拆分。
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @return 每个商户部门块下商品的跨来源合计及各来源统计
*/
List<HardCanteenProductApptExportRecord> listSalesExportProduct(@Param("dto") HardOrderDto dto);
List<HardCanteenProductApptExportRecord> listSalesExportRows(@Param("dto") HardOrderDto dto, DataScope dataScope);
Page<HardCarVo> proudctQuery(Page page, @Param("dto") HardCarDto dto);
@@ -39,7 +39,7 @@ public interface HardApptReservationService {
* @param apptDate 实际用餐日期
* @param apptRuleId 周期预约规则 ID;动态模式为空
* @param eligibilityDeadline 当前已校验预约窗口的截止时间,使用服务器现有预约时间口径
* @param usageEndDateTime 实际用餐餐段的结束时间;无法从有效餐段唯一解析时为空,禁止用于过期退款
* @param usageEndDateTime 实际用餐日的核销有效期结束时间,固定为该日 23:59:59,供订单固化后重扫使用
*/
record ReservationValidation(HardApptEntity appt, String hourType, LocalDate apptDate, Long apptRuleId,
LocalDateTime eligibilityDeadline, LocalDateTime usageEndDateTime) {
@@ -9,6 +9,7 @@ import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardAutoRefundTaskVo;
import com.bsd.pjcloud.hard.api.vo.order.HardExpiredRefundRescanResultVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppAutoRefundTaskVo;
import java.util.List;
@@ -63,6 +64,15 @@ public interface HardAutoRefundTaskService extends IService<HardAutoRefundTaskEn
/** 查询已到期且仍可整单退款的每日菜谱预约候选任务。 */
Page<HardAutoRefundTaskVo> pageExpiredCandidates(Page<HardAutoRefundTaskEntity> page, HardAutoRefundTaskPageDto dto);
/**
* 按商户当前过期预约配置重扫历史已支付未核销订单,补建或刷新任务并立即执行已到期任务。
*
* @param mchId 需要重扫的商户 ID
* @param operatorUserId 发起重扫的后台用户 ID,用于尝试日志审计
* @return 本轮扫描、任务同步和退款执行数量
*/
HardExpiredRefundRescanResultVo rescanExpiredReserveOrders(Long mchId, Long operatorUserId);
/**
* 查询任务的只读尝试日志,按尝试序号升序返回。
*
@@ -2,8 +2,6 @@ package com.bsd.pjcloud.hard.service;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.hard.api.dto.order.HardCanteenUserApptQueryDto;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo;
import java.util.List;
@@ -31,20 +29,4 @@ public interface HardCanteenApptTotalExportService {
* @return 用户预约导出行
*/
List<HardCanteenUserApptExcelVo> userReservationExport(HardCanteenUserApptQueryDto query);
/**
* 提交菜品预约异步导出任务。
*
* @param query 菜品预约统计筛选条件
* @return 可供前端轮询的初始任务进度
*/
AsyncTaskProgressVO submitProductReservationExport(HardOrderDto query);
/**
* 查询菜品预约自定义 Excel 行,供同步下载和异步写入复用。
*
* @param query 菜品预约统计筛选条件
* @return 按日期餐段、商户部门、商品三级结构排列的导出行
*/
List<HardCanteenProductApptExcelVo> productReservationExport(HardOrderDto query);
}
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.hard.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.hard.api.dto.order.HardCarDto;
@@ -78,4 +79,20 @@ public interface HardCanteenTotalService {
* @return 日期餐段、商户部门、商品三级排列的导出行
*/
List<HardCanteenProductApptExcelVo> exportSales(HardOrderDto dto, DataScope dataScope);
/**
* 提交食堂菜品销售统计异步导出任务。
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
*/
AsyncTaskProgressVO submitProductSalesExport(HardOrderDto dto);
/**
* 提交食堂部门销售统计异步导出任务。
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
*/
AsyncTaskProgressVO submitDepartmentSalesExport(HardOrderDto dto);
}
@@ -0,0 +1,30 @@
package com.bsd.pjcloud.hard.service;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import jakarta.servlet.http.HttpServletResponse;
/**
* 硬件订单导出服务。
*
* <p>该服务独立承担导出条件冻结、数据权限、分批查询、模板写入、进度上报和文件中心上传,
* 订单查询服务只保留订单业务查询职责。</p>
*/
public interface HardOrderExportService {
/**
* 提交硬件订单异步分批导出任务。
*
* @param dto 订单页面当前筛选条件;orderIds 非空时仅导出勾选订单
* @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
*/
AsyncTaskProgressVO submitExport(HardOrderDto dto);
/**
* 兼容旧页面的同步导出入口,同样按页查询并通过临时文件流式生成工作簿。
*
* @param response 当前 HTTP 下载响应,方法会写入完整 XLSX 文件
* @param dto 订单页面当前筛选条件
*/
void export(HttpServletResponse response, HardOrderDto dto);
}
@@ -358,11 +358,10 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
throw new RuntimeException(availability.unavailableReason());
}
return new ReservationValidation(appt, appt.getHourType(), targetDate, null, availability.endDateTime(),
availability.endDateTime());
resolveUsageEndDateTime(targetDate));
}
private ReservationValidation validateCycleReservation(Long mchId, Long apptRuleId, String hourType,
LocalDate apptDate, LocalDateTime now) {
private ReservationValidation validateCycleReservation(Long mchId, Long apptRuleId, String hourType, LocalDate apptDate, LocalDateTime now) {
if (apptDate == null || HardHourTypeEnum.valueOfByType(hourType) == null) {
throw new RuntimeException("周期预约需要选择预约日期和餐端");
}
@@ -379,33 +378,18 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
throw new RuntimeException(match.availability().unavailableReason());
}
return new ReservationValidation(null, hourType, apptDate, selected.getId(), match.availability().endDateTime(),
resolveCycleUsageEndDateTime(mchId, hourType, apptDate));
resolveUsageEndDateTime(apptDate));
}
/**
* 周期预约的开放窗口结束时间不是餐段结束时间;这里按已启用的实际 hard_appt 餐段解析,
* 无法唯一解析时返回空值,调用方会放弃过期退款快照以避免误退。
* 预约订单的核销有效期按实际用餐日全天计算,统一固化为当天 23:59:59。
* 该口径不读取当前餐段配置,避免预约配置高频调整后影响已创建订单。
*
* @param mchId 订单所属商户 ID
* @param hourType 订单选中的标准餐段编码
* @param apptDate 实际用餐日期
* @return 匹配餐段的实际结束时间;缺失、重复或时间配置异常时返回 {@code null}
* @return 当天 23:59:59;用餐日期为空时返回 {@code null}
*/
private LocalDateTime resolveCycleUsageEndDateTime(Long mchId, String hourType, LocalDate apptDate) {
List<HardApptEntity> candidates = hardApptService.list(new LambdaQueryWrapper<HardApptEntity>()
.eq(HardApptEntity::getMchId, mchId)
.eq(HardApptEntity::getHourType, hourType)
.eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode())
.orderByAsc(HardApptEntity::getId));
if (candidates.size() != 1) {
return null;
}
HardApptEntity appt = candidates.get(0);
HardApptTimeTypeEnum endType = HardApptTimeTypeEnum.valueOfByType(appt.getEndTimeType());
if (endType == null || appt.getEndTime() == null) {
return null;
}
return apptDate.plusDays(endType.getDay()).atTime(appt.getEndTime());
private LocalDateTime resolveUsageEndDateTime(LocalDate apptDate) {
return apptDate == null ? null : apptDate.atTime(23, 59, 59);
}
private CycleMatch matchCycleRule(HardApptCycleRuleVo rule, String hourType,
@@ -34,6 +34,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardAppCreateThirdDoorOrderVo;
import com.bsd.pjcloud.hard.mapper.HardAuthMapper;
import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
import com.bsd.pjcloud.hard.service.*;
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -62,8 +63,12 @@ public class HardAuthServiceImpl extends ServiceImpl<HardAuthMapper, HardAuthEnt
private final RemoteUserService remoteUserService;
private final RemoteAppUserService remoteAppUserService;
/** 授权用户身份筛选与展示资料装配器。 */
private final HardUserIdentitySupport userIdentitySupport;
@Override
public Page<HardAuthVo> scopePageJoin(Page page, HardAuthDto dto, DataScope dataScope) {
prepareIdentityQuery(dto);
Page<HardAuthVo> pageList = baseMapper.scopePageJoin(page, dto, dataScope);
disposeList(pageList.getRecords(), dto);
return pageList;
@@ -237,14 +242,18 @@ public class HardAuthServiceImpl extends ServiceImpl<HardAuthMapper, HardAuthEnt
HardUtil.convertHardList(list, CollectionUtils.convertMap(hardList, HardManagerEntity::getId));
}
}
// 用户
List<Long> userIds = list.stream().map(HardAuthVo::getUserId).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
R<List<UserVO>> userList = remoteUserService.getUserDeptByIds(new ArrayList<>(userIds));
if (ObjectUtil.isNotEmpty(userList.getData())) {
DataUtil.convertUserDeptList(list, CollectionUtils.convertMap(userList.getData(), UserVO::getUserId));
}
}
userIdentitySupport.fillUserInfo(list, true);
}
/** 将授权用户身份条件预筛选为授权记录可直接使用的用户主键集合。 */
private void prepareIdentityQuery(HardAuthDto 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);
}
/**
@@ -268,4 +277,4 @@ public class HardAuthServiceImpl extends ServiceImpl<HardAuthMapper, HardAuthEnt
.append("使用本智能柜设备即视为您已阅读并同意以上所有条款。请谨慎使用,确保遵守使用规定。");
return strBuilder.toString();
}
}
}
@@ -29,6 +29,7 @@ import com.bsd.pjcloud.hard.api.vo.HardBroadcastVo;
import com.bsd.pjcloud.hard.mapper.HardBroadcastMapper;
import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
import com.bsd.pjcloud.hard.service.HardBroadcastService;
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
import com.bsd.pjcloud.mqtt.api.feign.RemoteMqttEventService;
import com.bsd.pjcloud.mqtt.constant.MqttActionEnum;
import com.bsd.pjcloud.mqtt.dto.hard.HardTakeFoodBroadcastMsg;
@@ -55,10 +56,14 @@ public class HardBroadcastServiceImpl extends ServiceImpl<HardBroadcastMapper, H
private final RemoteUserService remoteUserService;
/** 广播预定用户身份筛选与展示资料装配器。 */
private final HardUserIdentitySupport userIdentitySupport;
private final RemoteMqttEventService remoteMqttEventService;
@Override
public Page<HardBroadcastVo> scopePageJoin(Page page, HardBroadcastDto dto, DataScope dataScope) {
prepareIdentityQuery(dto);
Page<HardBroadcastVo> pageList = baseMapper.scopePageJoin(page, dto, dataScope);
disposeList(pageList.getRecords(), dto);
return pageList;
@@ -310,14 +315,18 @@ public class HardBroadcastServiceImpl extends ServiceImpl<HardBroadcastMapper, H
HardUtil.convertHardList(list, CollectionUtils.convertMap(hardList, HardManagerEntity::getId));
}
}
// 用户
List<Long> userIds = list.stream().map(HardBroadcastVo::getUserId).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
R<List<UserVO>> userList = remoteUserService.getUserDeptByIds(new ArrayList<>(userIds));
if (ObjectUtil.isNotEmpty(userList.getData())) {
DataUtil.convertUserDeptList(list, CollectionUtils.convertMap(userList.getData(), UserVO::getUserId));
}
}
userIdentitySupport.fillUserInfo(list, true);
}
/** 将广播预定用户身份条件预筛选为广播记录可直接使用的用户主键集合。 */
private void prepareIdentityQuery(HardBroadcastDto 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);
}
}
@@ -41,6 +41,7 @@ import com.bsd.pjcloud.hard.mapper.HardDoorFlowMapper;
import com.bsd.pjcloud.hard.mapper.HardThirdMiOrderMapper;
import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
import com.bsd.pjcloud.hard.service.*;
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdDoorApiService;
import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdKsService;
import com.bsd.pjcloud.thirdparty.dto.door.*;
@@ -88,11 +89,15 @@ public class HardDoorFlowServiceImpl extends ServiceImpl<HardDoorFlowMapper, Har
private final RemoteUserService remoteUserService;
private final RemoteWalletService remoteWalletService;
/** 开门流程用户身份筛选与展示资料装配器。 */
private final HardUserIdentitySupport userIdentitySupport;
/**
* 复杂查询
*/
@Override
public Page<HardDoorFlowVo> scopePageJoin(Page page, HardDoorFlowDto dto, DataScope dataScope) {
prepareIdentityQuery(dto);
Page<HardDoorFlowVo> pageList = baseMapper.scopePageJoin(page, dto, dataScope);
disposeList(pageList.getRecords(), dto);
return pageList;
@@ -427,14 +432,18 @@ public class HardDoorFlowServiceImpl extends ServiceImpl<HardDoorFlowMapper, Har
HardUtil.convertHardList(list, CollectionUtils.convertMap(hardList, HardManagerEntity::getId));
}
}
// 用户相关
List<Long> userIds = list.stream().map(HardDoorFlowVo::getUserId).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
R<List<UserVO>> userList = remoteUserService.getUserDeptByIds(new ArrayList<>(userIds));
if (ObjectUtil.isNotEmpty(userList.getData())) {
DataUtil.convertUserDeptList(list, CollectionUtils.convertMap(userList.getData(), UserVO::getUserId));
}
}
userIdentitySupport.fillUserInfo(list, true);
}
/** 将开门流程用户身份条件预筛选为流程记录可直接使用的用户主键集合。 */
private void prepareIdentityQuery(HardDoorFlowDto 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);
}
private void disposeList(HardDoorFlowVo detail) {
@@ -458,4 +467,4 @@ public class HardDoorFlowServiceImpl extends ServiceImpl<HardDoorFlowMapper, Har
private HardDoorFlowServiceImpl getSelf() {
return SpringUtil.getBean(getClass());
}
}
}
@@ -37,6 +37,7 @@ import com.bsd.pjcloud.hard.service.HardCarService;
import com.bsd.pjcloud.hard.service.HardInvoiceService;
import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.hard.service.HardOrderService;
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
import com.bsd.pjcloud.mqtt.api.feign.RemoteMqttEventService;
import com.bsd.pjcloud.mqtt.dto.hard.HardPrintMsg;
import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
@@ -90,8 +91,12 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
private final RemoteFileService fileService;
private final RemoteMqttEventService mqttEventService;
/** 开票记录用户身份筛选与展示资料装配器。 */
private final HardUserIdentitySupport userIdentitySupport;
@Override
public Page<HardInvoiceVo> scopeJoinPageList(Page page, HardInvoiceDto dto) {
prepareIdentityQuery(dto);
Page<HardInvoiceVo> list = baseMapper.scopePageList(page, dto, DataScope.of());
if (ObjectUtil.isNotEmpty(list.getRecords())) {
// 部门
@@ -102,14 +107,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
DataUtil.convertDeptList(list.getRecords(), deptList.getData());
}
}
// 用户
List<Long> userIds = list.getRecords().stream().map(HardInvoiceVo::getOrderUserId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
R<List<SysUser>> userList = userService.getUserById(new ArrayList<>(userIds));
if (ObjectUtil.isNotEmpty(userList.getData())) {
DataUtil.convertUserList(list.getRecords(), CollectionUtils.convertMap(userList.getData(), SysUser::getUserId));
}
}
userIdentitySupport.fillUserInfo(list.getRecords(), true);
// 商户
List<Long> mchIds = list.getRecords().stream().map(HardInvoiceVo::getMerchantId).distinct().toList();
if (ObjectUtil.isNotEmpty(mchIds)) {
@@ -122,6 +120,20 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
return list;
}
/**
* 将开票用户身份条件预筛选为订单用户主键集合。
*
* @param dto 开票记录查询条件;处理后保留最终用户主键集合
*/
private void prepareIdentityQuery(HardInvoiceDto dto) {
if (!userIdentitySupport.hasIdentityQuery(dto)) return;
dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, null, dto.getUserIds()));
dto.setIdentityTypes(null);
dto.setStudentInfo(null);
dto.setTeacherInfo(null);
dto.setHasFace(null);
}
@Override
public String generateInvoice(HardOrderEntity order) {
return generateInvoice(order, null);
@@ -303,4 +315,4 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
private HardInvoiceServiceImpl getSelf() {
return SpringUtil.getBean(getClass());
}
}
}
@@ -46,6 +46,7 @@ import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleLogMapper;
import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.*;
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppPlusPayService;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMicropayDto;
@@ -115,19 +116,16 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
/** 退款订单级分布式锁模板,与数据库订单行锁共同保护微服务和单体部署。 */
private final LockTemplate lockTemplate;
/** 售后申请用户身份筛选与展示资料装配器。 */
private final HardUserIdentitySupport userIdentitySupport;
@Override
public Page<HardAfterSaleVo> pageJoin(Page page, HardAfterSaleDto dto) {
prepareIdentityQuery(dto);
Page<HardAfterSaleVo> list = baseMapper.pageJoin(page, dto);
if (ObjectUtil.isNotEmpty(list.getRecords())) {
// 用户
List<Long> userIds = list.getRecords().stream().map(HardAfterSaleVo::getUserId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
R<List<SysUser>> userList = userService.getUserById(new ArrayList<>(userIds));
if (ObjectUtil.isNotEmpty(userList.getData())) {
DataUtil.convertUserList(list.getRecords(), CollectionUtils.convertMap(userList.getData(), SysUser::getUserId));
}
}
userIdentitySupport.fillUserInfo(list.getRecords(), true);
// 部门
List<Long> deptIds = list.getRecords().stream().map(HardAfterSaleVo::getDeptId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(deptIds)) {
@@ -151,16 +149,10 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
@Override
public Page<HardAfterSaleVo> scopePageList(Page page, HardAfterSaleDto dto) {
prepareIdentityQuery(dto);
Page<HardAfterSaleVo> list = baseMapper.scopePageJoinList(page, dto, null);
if (ObjectUtil.isNotEmpty(list.getRecords())) {
// 用户
List<Long> userIds = list.getRecords().stream().map(HardAfterSaleVo::getUserId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
R<List<SysUser>> userList = userService.getUserById(new ArrayList<>(userIds));
if (ObjectUtil.isNotEmpty(userList.getData())) {
DataUtil.convertUserList(list.getRecords(), CollectionUtils.convertMap(userList.getData(), SysUser::getUserId));
}
}
userIdentitySupport.fillUserInfo(list.getRecords(), true);
// 部门
List<Long> deptIds = list.getRecords().stream().map(HardAfterSaleVo::getDeptId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(deptIds)) {
@@ -174,6 +166,21 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
return list;
}
/**
* 将售后申请用户身份条件预筛选为售后记录可直接使用的用户主键集合。
*
* @param dto 售后记录查询条件;处理后保留最终用户主键集合
*/
private void prepareIdentityQuery(HardAfterSaleDto 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);
}
@Override
public Long create(HardAfterSaleDto dto) {
HardOrderVo order = validateOrderCanRefund(dto.getOrderId());
@@ -19,12 +19,14 @@ import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundAttemptResultEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardSaleStatusEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardAutoRefundTaskVo;
import com.bsd.pjcloud.hard.api.vo.order.HardExpiredRefundRescanResultVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppAutoRefundTaskVo;
import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleMapper;
import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundAttemptLogMapper;
@@ -108,6 +110,21 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
*/
private static final String SUPPORTED_ORDER_TYPE = "2";
/** 过期预约退款延迟允许的最小小时数。 */
private static final int MIN_EXPIRED_RESERVE_DELAY_HOURS = 1;
/** 过期预约退款延迟允许的最大小时数。 */
private static final int MAX_EXPIRED_RESERVE_DELAY_HOURS = 168;
/** 重扫单个订单时未同步任务,结果计数保持为零。 */
private static final int RESCAN_SYNC_SKIPPED = 0;
/** 重扫单个订单时新建任务,结果计数为一。 */
private static final int RESCAN_SYNC_CREATED = 1;
/** 重扫单个订单时刷新未尝试任务,结果计数为二。 */
private static final int RESCAN_SYNC_REFRESHED = 2;
/**
* 尝试日志 Mapper,用于保存每次自动、人工和开关跳过记录。
*/
@@ -118,7 +135,9 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
*/
private final HardAfterSaleMapper afterSaleMapper;
/** 订单 Mapper,用于过期候选列表补充预约日期和餐段快照。 */
/**
* 订单 Mapper,用于过期候选列表补充预约日期和餐段快照。
*/
private final com.bsd.pjcloud.hard.mapper.order.HardOrderMapper hardOrderMapper;
/**
@@ -154,7 +173,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 在售后申请事务内创建任务。配置服务异常、开关关闭或快照不完整时返回空并保留人工审核流程。
*
* @param order 已锁定的原订单
* @param order 已锁定的原订单
* @param afterSale 新建售后单
* @return 已创建任务;不符合免审条件时返回 {@code null}
*/
@@ -267,8 +286,8 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 使用订单 Redis 锁包裹尝试和失败状态落库,避免事务回滚释放行锁后被另一实例插入同一尝试序号。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
*/
@Override
@@ -311,14 +330,14 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 在创建系统售后前复核到期时间、任务状态和当前配置,避免开关已关闭时提前占用订单售后。
*
* @param taskId 过期预约任务主键
* @param triggerType 触发来源,人工业务退款不受当前开关控制
* @param taskId 过期预约任务主键
* @param triggerType 触发来源,人工业务退款不受当前开关控制
* @param operatorUserId 人工操作用户 ID,自动触发为空
* @return 当前允许准备系统售后时返回 {@code true}
*/
@Transactional(rollbackFor = Exception.class)
public boolean canPrepareExpiredReserveTask(Long taskId, HardAutoRefundTriggerTypeEnum triggerType,
Long operatorUserId) {
Long operatorUserId) {
HardAutoRefundTaskEntity task = baseMapper.selectByIdForUpdate(taskId);
if (!canExecute(task, triggerType)
|| !Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())) {
@@ -363,8 +382,8 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
* 在单个 Hardware 事务中锁任务、复核配置、调用 Wallet 并原子完成售后和任务状态。
* 资金调用异常会使本事务全部回滚,外层随后用独立事务写失败日志。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
*/
@Transactional(rollbackFor = Exception.class)
@@ -420,9 +439,9 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
* 记录一次成功完成的 Wallet 调用并原子推进售后和任务状态。恢复已到账记录时不再消耗自动尝试额度,
* 因为真正的资金调用已在此前回滚的 Hardware 事务中发生。
*
* @param task 已锁定任务
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
* @param task 已锁定任务
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
* @param countAutoAttempt 是否把本次调用计入八次自动资金尝试额度
*/
private void completeAttempt(HardAutoRefundTaskEntity task, HardAutoRefundTriggerTypeEnum triggerType,
@@ -478,10 +497,10 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 实际尝试失败后再次读取当前开关;若失败由关闭竞态导致则转为暂停且不消耗自动额度。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
* @param exception 本次执行异常
* @param exception 本次执行异常
*/
private void handleAttemptFailure(Long taskId, HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId,
Exception exception) {
@@ -517,13 +536,13 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
* 在当前订单 Redis 锁内探测 Wallet 已到账记录,并通过独立 Hardware 事务恢复本地状态。
* 此方法直接调用事务代理,不递归进入 {@link #execute(Long, HardAutoRefundTriggerTypeEnum, Long)},避免重复获取同一订单锁。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
* @return Wallet 已存在退款记录并完成本地恢复时返回 {@code true}
*/
private boolean tryRecoverPersistedWalletRefund(Long taskId, HardAutoRefundTriggerTypeEnum triggerType,
Long operatorUserId) {
Long operatorUserId) {
HardAutoRefundTaskEntity task = getById(taskId);
if (!hasPersistedWalletRefund(task)) {
return false;
@@ -536,8 +555,8 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
* 在独立 Hardware 事务中锁定任务并恢复 Wallet 已到账结果,不重新获取订单 Redis 锁。
* Wallet 的退款接口仍会按售后单 ID 严格校验钱包和金额后返回原记录。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
*/
@Transactional(rollbackFor = Exception.class)
@@ -553,13 +572,13 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 在当前 Hardware 事务中恢复已持久化的钱包退款。调用方已持有任务行锁,恢复过程不消耗自动额度。
*
* @param task 已锁定任务
* @param triggerType 触发来源
* @param task 已锁定任务
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
* @return Wallet 已存在退款记录并完成本地恢复时返回 {@code true}
*/
private boolean recoverPersistedWalletRefund(HardAutoRefundTaskEntity task,
HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId) {
HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId) {
if (!hasPersistedWalletRefund(task)) {
return false;
}
@@ -594,10 +613,10 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 独立事务记录一次真实资金尝试失败,并根据自动次数计算固定重试时间或耗尽状态。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
* @param errorMessage 已截断的非敏感错误信息
* @param errorMessage 已截断的非敏感错误信息
*/
@Transactional(rollbackFor = Exception.class)
public void recordFailureInTransaction(Long taskId, HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId,
@@ -658,8 +677,8 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 已发起调用后发现开关关闭时写一条跳过日志并暂停,不增加自动尝试次数。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
*/
@Transactional(rollbackFor = Exception.class)
@@ -675,7 +694,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 更新配置不可用暂停状态并设置下一轮复查时间。
*
* @param task 已锁定任务
* @param task 已锁定任务
* @param reason 暂停原因
*/
private void pauseConfigUnavailable(HardAutoRefundTaskEntity task, String reason) {
@@ -688,8 +707,8 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 首次遇到关闭开关时记录一次跳过审计,持续暂停扫描不会重复增加日志。
*
* @param task 已锁定任务
* @param triggerType 触发来源
* @param task 已锁定任务
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
*/
private void pauseForDisabledSwitch(HardAutoRefundTaskEntity task, HardAutoRefundTriggerTypeEnum triggerType,
@@ -721,7 +740,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 判断当前任务状态和触发来源是否允许进入资金尝试。
*
* @param task 已锁定任务
* @param task 已锁定任务
* @param triggerType 触发来源
* @return 允许执行时返回 {@code true}
*/
@@ -755,7 +774,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 查询当前配置并统一校验 Feign 业务响应,异常由调用方区分受理降级或任务暂停。
*
* @param mchId 商户 ID
* @param mchId 商户 ID
* @param orderType 硬件订单类型编码
* @return 当前配置;未配置时返回 {@code null}
*/
@@ -779,7 +798,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
*
* @param configId 快照配置主键
* @param ruleType 快照规则名称
* @param current 当前 Wallet 配置
* @param current 当前 Wallet 配置
* @return 配置身份和规则一致时返回 {@code true}
*/
private boolean matchesSnapshot(Long configId, String ruleType, MchAutoRefundConfigVo current) {
@@ -788,7 +807,9 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
&& Objects.equals(current.getOrderType(), SUPPORTED_ORDER_TYPE);
}
/** 校验过期任务只匹配创建时配置身份和过期退款开关,不读取或覆盖订单延迟快照。 */
/**
* 校验过期任务只匹配创建时配置身份和过期退款开关,不读取或覆盖订单延迟快照。
*/
private boolean matchesExpiredSnapshot(HardAutoRefundTaskEntity task, MchAutoRefundConfigVo current) {
return current != null && Objects.equals(current.getId(), task.getConfigId())
&& Objects.equals(current.getOrderType(), SUPPORTED_ORDER_TYPE);
@@ -812,7 +833,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
* 分页查询任务并尽力补充关联展示数据;远程展示信息异常不会影响技术人员查看任务本体。
*
* @param page 分页参数
* @param dto 服务端已注入商户数据范围的筛选条件
* @param dto 服务端已注入商户数据范围的筛选条件
* @return 任务分页
*/
@Override
@@ -922,7 +943,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 仅允许重试耗尽任务进入人工额外尝试,重复点击仍由任务行锁复查状态。
*
* @param taskId 任务主键
* @param taskId 任务主键
* @param operatorUserId 当前管理用户 ID
*/
@Override
@@ -941,7 +962,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
* 管理端逐单执行过期预约业务退款,候选任务不受技术重试状态机权限限制。
* 状态机为保留失败日志会吸收资金异常,因此执行后必须复查最终状态,避免管理端把失败提示成成功。
*
* @param taskId 过期预约任务主键
* @param taskId 过期预约任务主键
* @param operatorUserId 当前业务操作用户 ID
*/
@Override
@@ -965,10 +986,11 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
}
}
/** 查询已到期、未失败耗尽且仍可整单退款的任务,失败任务留在技术任务页处理。 */
/**
* 查询已到期、未失败耗尽且仍可整单退款的任务,失败任务留在技术任务页处理。
*/
@Override
public Page<HardAutoRefundTaskVo> pageExpiredCandidates(Page<HardAutoRefundTaskEntity> page,
HardAutoRefundTaskPageDto dto) {
public Page<HardAutoRefundTaskVo> pageExpiredCandidates(Page<HardAutoRefundTaskEntity> page, HardAutoRefundTaskPageDto dto) {
LocalDateTime now = LocalDateTime.now();
Page<HardAutoRefundTaskEntity> entityPage = baseMapper.pageExpiredCandidates(page, dto, now);
List<HardAutoRefundTaskVo> records = BeanUtils.toBean(entityPage.getRecords(), HardAutoRefundTaskVo.class);
@@ -981,7 +1003,193 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
return result;
}
/** 批量补充过期候选订单日期和餐段,避免把配置变更后的规则重新解析为退款时间。 */
/**
* 按商户当前过期预约配置重扫历史订单,补建或刷新未尝试任务,并立即执行已经到期的任务。
* 资金动作仍统一经过现有订单锁、售后校验、Wallet 幂等和自动重试状态机;开关关闭时只同步暂停任务,绝不调用退款。
*
* @param mchId 精确商户 ID
* @param operatorUserId 发起重扫的后台用户 ID,用于退款尝试审计
* @return 本轮扫描、任务同步和退款执行数量
*/
@Override
public HardExpiredRefundRescanResultVo rescanExpiredReserveOrders(Long mchId, Long operatorUserId) {
if (mchId == null) {
throw new RuntimeException("商户不能为空");
}
MchAutoRefundConfigVo config = loadCurrentConfig(mchId, SUPPORTED_ORDER_TYPE);
if (config == null || config.getId() == null || !Objects.equals(config.getMchId(), mchId) || !Objects.equals(config.getOrderType(), SUPPORTED_ORDER_TYPE)) {
throw new RuntimeException("当前商户未配置过期预约退款规则");
}
int delayHours = config.getExpiredReserveRefundDelayHours() == null ? 0 : config.getExpiredReserveRefundDelayHours();
if (delayHours < MIN_EXPIRED_RESERVE_DELAY_HOURS || delayHours > MAX_EXPIRED_RESERVE_DELAY_HOURS) {
throw new RuntimeException("过期预约退款延迟必须在1到168小时之间");
}
boolean enabled = Objects.equals(config.getExpiredReserveRefundEnabled(), YesNoEnum.YES.getCode());
String restoreInventory = Objects.equals(config.getExpiredReserveRefundRestoreInventory(), YesNoEnum.YES.getCode()) ? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode();
List<HardOrderEntity> orders = baseMapper.listExpiredRescanOrders(mchId);
HardExpiredRefundRescanResultVo result = new HardExpiredRefundRescanResultVo();
result.setScannedOrderCount(orders == null ? 0 : orders.size());
result.setCreatedTaskCount(0);
result.setRefreshedTaskCount(0);
result.setExecutedTaskCount(0);
result.setSuccessTaskCount(0);
result.setSkippedOrderCount(0);
if (orders == null || orders.isEmpty()) {
return result;
}
LocalDateTime now = LocalDateTime.now();
for (HardOrderEntity order : orders) {
boolean taskSynced = false;
try {
LocalDateTime usageEndTime = resolveExpiredRefundUsageEndTimeSnapshot(order);
if (usageEndTime == null) {
result.setSkippedOrderCount(result.getSkippedOrderCount() + 1);
log.warn("|{}|立即重扫跳过缺少可靠订单时间快照的订单,orderId:{}", TAG, order.getId());
continue;
}
LocalDateTime triggerTime = usageEndTime.plusHours(delayHours);
LockInfo syncLock = lockTemplate.lock(REFUND_ORDER_LOCK_PREFIX + order.getId(),
REFUND_ORDER_LOCK_EXPIRE_MILLIS, REFUND_ORDER_LOCK_WAIT_MILLIS);
if (syncLock == null) {
result.setSkippedOrderCount(result.getSkippedOrderCount() + 1);
continue;
}
int syncResult;
try {
syncResult = getSelf().syncExpiredRescanTask(order, config, triggerTime, restoreInventory, enabled);
} finally {
try {
lockTemplate.releaseLock(syncLock);
} catch (Exception exception) {
log.warn("|{}|释放立即重扫订单锁失败,orderId:{},exceptionType:{}", TAG, order.getId(),
exception.getClass().getSimpleName());
}
}
if (syncResult == RESCAN_SYNC_CREATED) {
result.setCreatedTaskCount(result.getCreatedTaskCount() + 1);
} else if (syncResult == RESCAN_SYNC_REFRESHED) {
result.setRefreshedTaskCount(result.getRefreshedTaskCount() + 1);
} else {
result.setSkippedOrderCount(result.getSkippedOrderCount() + 1);
continue;
}
taskSynced = true;
if (enabled && !triggerTime.isAfter(now)) {
HardAutoRefundTaskEntity task = baseMapper.selectOne(new LambdaQueryWrapper<HardAutoRefundTaskEntity>()
.eq(HardAutoRefundTaskEntity::getOrderId, order.getId())
.eq(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()));
if (task != null && Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.PENDING.getStatus())) {
result.setExecutedTaskCount(result.getExecutedTaskCount() + 1);
execute(task.getId(), HardAutoRefundTriggerTypeEnum.EXPIRED_RESCAN, operatorUserId);
HardAutoRefundTaskEntity completed = getById(task.getId());
if (completed != null && Objects.equals(completed.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) {
result.setSuccessTaskCount(result.getSuccessTaskCount() + 1);
}
}
}
} catch (Exception exception) {
if (!taskSynced) {
result.setSkippedOrderCount(result.getSkippedOrderCount() + 1);
}
log.warn("|{}|立即重扫订单失败,orderId:{},exceptionType:{}", TAG, order.getId(),
exception.getClass().getSimpleName());
}
}
return result;
}
/**
* 解析订单自身保存的过期退款时间快照。新订单优先使用专用时间字段;历史订单仅使用订单上的预约日期
* 兜底为当天 23:59:59,绝不读取当前预约配置或旧的自动退款截止时间。
*
* @param order 待重扫的已支付预约订单
* @return 可信的核销有效期结束时间;订单没有预约日期时返回 {@code null}
*/
private LocalDateTime resolveExpiredRefundUsageEndTimeSnapshot(HardOrderEntity order) {
if (order == null) {
return null;
}
if (order.getExpiredRefundUsageEndTime() != null) {
return order.getExpiredRefundUsageEndTime();
}
return order.getApptUseDate() == null ? null : order.getApptUseDate().atTime(23, 59, 59);
}
/**
* 同步单个历史订单的过期任务。已成功、已创建售后或已产生资金尝试的任务不允许被新配置覆盖,避免重扫造成重复退款。
*
* @param order 历史已支付未核销订单
* @param config 当前商户过期退款配置
* @param triggerTime 按当前延迟重新计算的触发时间
* @param restoreInventory 当前退库存快照
* @param enabled 当前过期退款开关
* @return 新建、刷新或跳过结果编码
*/
@Transactional(rollbackFor = Exception.class)
public int syncExpiredRescanTask(HardOrderEntity order, MchAutoRefundConfigVo config,
LocalDateTime triggerTime, String restoreInventory, boolean enabled) {
if (order == null || order.getId() == null || config == null || config.getId() == null || triggerTime == null) {
return RESCAN_SYNC_SKIPPED;
}
HardOrderEntity currentOrder = hardOrderMapper.selectByIdForUpdate(order.getId());
if (currentOrder == null || !Objects.equals(currentOrder.getMerchantId(), config.getMchId())
|| !Objects.equals(currentOrder.getOrderType(), HardOrderTypeEnum.RESERVE.getType())
|| !Objects.equals(currentOrder.getOrderStatus(), HardOrderStatusEnum.PAY.getType())
|| !Objects.equals(currentOrder.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType())
|| currentOrder.getPayPrice() == null || currentOrder.getPayPrice() <= 0
|| currentOrder.getRefundPrice() != null && currentOrder.getRefundPrice() > 0) {
return RESCAN_SYNC_SKIPPED;
}
Long conflictingAfterSaleCount = afterSaleMapper.selectCount(new LambdaQueryWrapper<HardAfterSaleEntity>()
.eq(HardAfterSaleEntity::getOrderId, currentOrder.getId())
.in(HardAfterSaleEntity::getStatus,
List.of(HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus())));
if (conflictingAfterSaleCount != null && conflictingAfterSaleCount > 0) {
return RESCAN_SYNC_SKIPPED;
}
HardAutoRefundTaskEntity existing = baseMapper.selectOne(new LambdaQueryWrapper<HardAutoRefundTaskEntity>()
.eq(HardAutoRefundTaskEntity::getOrderId, currentOrder.getId())
.eq(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()));
if (existing != null) {
boolean refreshableStatus = List.of(HardAutoRefundTaskStatusEnum.PENDING.getStatus(),
HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus(),
HardAutoRefundTaskStatusEnum.CONFIG_UNAVAILABLE.getStatus()).contains(existing.getStatus());
if (!refreshableStatus || !Objects.equals(existing.getMchId(), currentOrder.getMerchantId())
|| existing.getAfterSaleId() != null || safeCount(existing.getAutoAttemptCount()) > 0) {
return RESCAN_SYNC_SKIPPED;
}
existing.setConfigId(config.getId());
existing.setTriggerTime(triggerTime);
existing.setRestoreInventory(restoreInventory);
existing.setStatus(enabled ? HardAutoRefundTaskStatusEnum.PENDING.getStatus()
: HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus());
existing.setNextRetryTime(enabled ? triggerTime : null);
existing.setLastError(null);
if (baseMapper.updateById(existing) != 1) {
throw new RuntimeException("刷新过期预约退款任务失败");
}
return RESCAN_SYNC_REFRESHED;
}
HardAutoRefundTaskEntity task = HardAutoRefundTaskEntity.builder()
.taskType(HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
.orderId(currentOrder.getId()).mchId(currentOrder.getMerchantId())
.configId(config.getId()).triggerTime(triggerTime)
.restoreInventory(restoreInventory)
.status(enabled ? HardAutoRefundTaskStatusEnum.PENDING.getStatus()
: HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus())
.autoAttemptCount(0).totalAttemptCount(0)
.nextRetryTime(enabled ? triggerTime : null)
.build();
if (baseMapper.insert(task) != 1) {
throw new RuntimeException("创建过期预约退款任务失败");
}
return RESCAN_SYNC_CREATED;
}
/**
* 批量补充过期候选订单日期和餐段,避免把配置变更后的规则重新解析为退款时间。
*/
private void fillOrderData(List<HardAutoRefundTaskVo> records) {
List<Long> orderIds = records.stream().map(HardAutoRefundTaskVo::getOrderId).filter(Objects::nonNull).distinct().toList();
if (orderIds.isEmpty()) return;
@@ -1092,7 +1300,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
.and(expired -> expired.ne(HardAutoRefundTaskEntity::getTaskType,
HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
.or(expiredType -> expiredType.eq(HardAutoRefundTaskEntity::getTaskType,
HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
.le(HardAutoRefundTaskEntity::getTriggerTime, now)))))
.orderByAsc(HardAutoRefundTaskEntity::getNextRetryTime)
.orderByAsc(HardAutoRefundTaskEntity::getId)
@@ -1187,7 +1395,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
* 使用业务提示和原异常构造配置查询异常。
*
* @param message 不包含敏感信息的失败原因
* @param cause 原始远程异常
* @param cause 原始远程异常
*/
private CurrentConfigLookupException(String message, Throwable cause) {
super(message, cause);
@@ -17,9 +17,6 @@ import com.bsd.pjcloud.admin.api.vo.UserVO;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteDictService;
import com.bsd.pjcloud.admin.api.feign.RemoteFileService;
import com.bsd.pjcloud.canteen.api.feign.RemoteDailyMenuService;
import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult;
@@ -32,15 +29,11 @@ import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardCanteenUserApptQueryDto;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord;
import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo;
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.HardCanteenApptTotalExportService;
@@ -54,7 +47,6 @@ import org.springframework.validation.annotation.Validated;
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.nio.file.Path;
@@ -63,8 +55,6 @@ import java.time.LocalTime;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
@@ -75,10 +65,9 @@ import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 食堂预约统计导出服务实现。
* 用户食堂预约统计导出服务实现。
*
* <p>菜品导出以预约使用日期为批次边界,每完成一个日期就推进一次 Redis 进度;Excel 数据始终通过
* Easy Excel 追加写入,避免把整个日期范围的商品明细一次性保留在内存中。</p>
* <p>按权限用户分批查询每日预约并流式写入 Excel,避免长日期范围和大量用户一次性占用过多内存。</p>
*/
@Slf4j
@Validated
@@ -131,33 +120,9 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
private static final DateTimeFormatter EXCEL_DATE_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/** 菜品预约导出进度的 Redis Key 命名空间。 */
private static final String PRODUCT_EXPORT_REDIS_KEY = "hard:canteen-product-appt:export";
/** 菜品预约导出任务名称。 */
private static final String PRODUCT_EXPORT_TASK_NAME = "菜品预约统计导出";
/** 异步任务创建时的占位总数,真实日期数量在后台查询后更新。 */
private static final int INITIAL_PROGRESS_TOTAL = 1;
/** 菜品预约导出工作表名称。 */
private static final String PRODUCT_EXPORT_SHEET_NAME = "菜品预约统计";
/** 文件中心存储目录。 */
private static final String EXPORT_FILE_DIR = "hard-export";
/** 文件中心业务类型编码。 */
private static final String PRODUCT_EXPORT_FILE_TYPE = "HARD_CANTEEN_PRODUCT_APPT_EXPORT";
/** Excel 文件 MIME 类型。 */
private static final String EXCEL_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
/** 用餐时间段字典类型。 */
private static final String MEAL_SEGMENT_DICT_TYPE = "daily_menu";
/** Excel 文件名时间格式。 */
private static final DateTimeFormatter FILE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
/** 用户预约查询允许的最大自然月跨度,开始和结束日期均计入查询范围。 */
private static final long MAX_QUERY_MONTHS = 2L;
@@ -206,9 +171,6 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
/** 菜品预约聚合 Mapper。 */
private final HardCarMapper hardCarMapper;
/** 每日菜谱远程服务,用于展开套餐组成。 */
private final RemoteDailyMenuService remoteDailyMenuService;
/** 部门远程服务,用于补齐预约人所属部门名称。 */
private final RemoteDeptService remoteDeptService;
@@ -1303,459 +1265,4 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
}
}
/**
* 提交菜品预约异步导出任务。
*
* @param query 菜品预约统计筛选条件
* @return 初始异步任务进度
*/
@Override
public AsyncTaskProgressVO submitProductReservationExport(HardOrderDto query) {
HardOrderDto querySnapshot = copyProductQuery(query);
return asyncTaskExecutorService.submitWithResult(PRODUCT_EXPORT_REDIS_KEY, PRODUCT_EXPORT_TASK_NAME,
INITIAL_PROGRESS_TOTAL, reporter -> exportProducts(querySnapshot, reporter));
}
/**
* 同步查询菜品预约自定义 Excel 行。
*
* @param query 菜品预约统计筛选条件
* @return 三级层级导出行
*/
@Override
public List<HardCanteenProductApptExcelVo> productReservationExport(HardOrderDto query) {
HardOrderDto querySnapshot = copyProductQuery(query);
List<HardCanteenProductApptExcelVo> rows = new ArrayList<>();
for (LocalDate date : getQueryDates(querySnapshot)) {
HardOrderDto dateQuery = copyProductQuery(querySnapshot);
dateQuery.setApptUseDateTimeList(new LocalDateTime[]{date.atStartOfDay(), date.atTime(23, 59, 59)});
rows.addAll(buildProductRows(dateQuery, date));
}
return rows;
}
/**
* 后台按日期批次生成菜品预约工作簿并上传文件中心。
*
* @param query 后台线程独占的查询快照
* @param reporter 当前异步任务进度报告器
* @return 文件中心结果
*/
private AsyncTaskResult exportProducts(HardOrderDto query, AsyncTaskProgressReporter reporter) {
List<LocalDate> dates = getQueryDates(query);
reporter.updateTotal(Math.max(1, dates.size() + 1), "已确定 " + dates.size() + " 个用餐日期,开始分批导出");
Path outputPath = null;
String fileName = "菜品预约统计_" + LocalDateTime.now().format(FILE_TIME_FORMATTER) + ".xlsx";
try {
outputPath = Files.createTempFile("canteen-product-appt-export-", ".xlsx");
long rowCount;
try (ExcelWriter writer = EasyExcel.write(outputPath.toFile(), HardCanteenProductApptExcelVo.class)
.registerWriteHandler(new HardCanteenProductApptExportMergeHandler()).build()) {
WriteSheet sheet = EasyExcel.writerSheet(PRODUCT_EXPORT_SHEET_NAME).build();
rowCount = 0L;
for (LocalDate date : dates) {
HardOrderDto dateQuery = copyProductQuery(query);
dateQuery.setApptUseDateTimeList(new LocalDateTime[]{date.atStartOfDay(), date.atTime(23, 59, 59)});
List<HardCanteenProductApptExcelVo> batchRows = buildProductRows(dateQuery, date);
if (!batchRows.isEmpty()) {
writer.write(batchRows, sheet);
rowCount += batchRows.size();
}
reporter.advance("已处理 " + date + ",写入 " + rowCount + " 行");
}
if (rowCount == 0L) {
throw new IllegalStateException("没有符合当前条件的菜品预约数据");
}
}
String fileUrl = upload(outputPath, fileName);
reporter.advance("菜品预约导出文件已生成");
return AsyncTaskResult.file(fileUrl, fileName);
} catch (IllegalStateException exception) {
throw exception;
} catch (Exception exception) {
log.error("菜品预约统计导出失败,fileName:{}", fileName, exception);
throw new IllegalStateException("生成菜品预约导出文件失败", exception);
} finally {
if (outputPath != null) {
try {
Files.deleteIfExists(outputPath);
} catch (Exception exception) {
log.warn("删除菜品预约导出临时文件失败,path:{}", outputPath, exception);
}
}
}
}
/**
* 查询一个日期批次的三层数据并转换为 Excel 行。
*
* @param query 日期批次查询条件
* @param fixedDate 已知的批次日期;同步查询传空时从查询结果读取
* @return 当前批次的汇总、商户部门、商品明细和商品统计行
*/
private List<HardCanteenProductApptExcelVo> buildProductRows(HardOrderDto query, LocalDate fixedDate) {
List<HardCanteenProductTotalVo> summaries = hardCarMapper.listApptExportSummary(query);
List<HardCanteenProductApptExportRecord> merchants = hardCarMapper.listApptExportMerchant(query);
List<HardCanteenProductApptExportRecord> products = hardCarMapper.listApptExportProduct(query);
if (summaries == null || summaries.isEmpty()) {
return List.of();
}
Map<Long, String> merchantNames = fetchMerchantNames(merchants);
Map<Long, String> departmentNames = fetchDepartmentNames(merchants);
Map<String, String> hourNames = fetchHourNames();
Map<String, DailyMenuAppVO> menuMap = fetchDailyMenus(products, fixedDate);
Map<String, List<HardCanteenProductApptExportRecord>> merchantGroups = merchants.stream()
.collect(Collectors.groupingBy(this::summaryGroupKey, LinkedHashMap::new, Collectors.toList()));
Map<String, List<HardCanteenProductApptExportRecord>> productGroups = products.stream()
.collect(Collectors.groupingBy(this::groupKey, LinkedHashMap::new, Collectors.toList()));
List<HardCanteenProductApptExcelVo> rows = new ArrayList<>();
for (HardCanteenProductTotalVo summary : summaries) {
LocalDate date = fixedDate != null ? fixedDate
: summary.getCreateTime() == null ? null : summary.getCreateTime().toLocalDate();
if (date == null) {
log.warn("菜品预约导出汇总记录缺少预约日期,hourType:{},跳过该记录", summary.getHourType());
continue;
}
String hourType = summary.getHourType();
String summaryKey = date + "|" + hourType;
List<HardCanteenProductApptExportRecord> merchantRows = merchantGroups.getOrDefault(summaryKey, List.of());
Map<String, List<HardCanteenProductApptExcelVo>> detailRowsByMerchant = new LinkedHashMap<>();
Map<String, Long> detailCountsByMerchant = new HashMap<>();
Map<String, Long> detailPricesByMerchant = new HashMap<>();
long summaryProductCount = 0L;
long summaryTotalPrice = 0L;
for (HardCanteenProductApptExportRecord merchant : merchantRows) {
String merchantKey = groupKey(merchant);
List<HardCanteenProductApptExcelVo> detailRows = productGroups
.getOrDefault(merchantKey, List.of()).stream()
.flatMap(product -> toProductRows(product,
menuMap.get(menuKey(product, fixedDate != null ? fixedDate : date))).stream())
.toList();
List<HardCanteenProductApptExcelVo> statisticRows = buildProductStatistics(detailRows);
long detailCount = detailRows.stream().mapToLong(detail -> valueOrZero(detail.getProductCount())).sum();
long detailPrice = detailRows.stream().mapToLong(detail -> yuanToCents(detail.getProductTotalPrice())).sum();
// 每日菜谱查询失败时仍保留 SQL 的有效预约统计,避免导出因远程菜谱暂时不可用而丢失金额。
if (detailRows.isEmpty()) {
detailCount = valueOrZero(merchant.getPayCount());
detailPrice = valueOrZero(merchant.getTotalPrice());
}
List<HardCanteenProductApptExcelVo> merchantProductRows = new ArrayList<>(
detailRows.size() + statisticRows.size());
merchantProductRows.addAll(detailRows);
merchantProductRows.addAll(statisticRows);
detailRowsByMerchant.put(merchantKey, merchantProductRows);
detailCountsByMerchant.put(merchantKey, detailCount);
detailPricesByMerchant.put(merchantKey, detailPrice);
summaryProductCount += detailCount;
summaryTotalPrice += detailPrice;
}
if (merchantRows.isEmpty()) {
summaryProductCount = valueOrZero(summary.getPayCount());
summaryTotalPrice = valueOrZero(summary.getTotalPrice());
}
HardCanteenProductApptExcelVo summaryRow = new HardCanteenProductApptExcelVo();
summaryRow.setSummaryDate(date);
summaryRow.setSummaryHourType(resolveHourName(hourType, hourNames));
summaryRow.setSummaryPayUserCount(valueOrZero(summary.getPayUserCount()));
summaryRow.setSummaryTimeCount(valueOrZero(summary.getTimeCount()));
// 汇总数量必须与下方已展开的商品明细一致;套餐按组成商品份数计入,而不是只计套餐份数。
summaryRow.setSummaryPayCount(summaryProductCount);
summaryRow.setSummaryTotalPrice(centsToYuan(summaryTotalPrice));
rows.add(summaryRow);
for (HardCanteenProductApptExportRecord merchant : merchantRows) {
HardCanteenProductApptExcelVo merchantRow = new HardCanteenProductApptExcelVo();
merchantRow.setMerchantName(merchantNames.getOrDefault(merchant.getMerchantId(), "商户信息未记录"));
merchantRow.setDepartmentName(departmentNames.getOrDefault(merchant.getUserDeptId(), "部门信息未记录"));
merchantRow.setMerchantDate(date);
merchantRow.setMerchantHourType(resolveHourName(hourType, hourNames));
merchantRow.setMerchantPayUserCount(valueOrZero(merchant.getPayUserCount()));
merchantRow.setMerchantTimeCount(valueOrZero(merchant.getTimeCount()));
String merchantKey = groupKey(merchant);
merchantRow.setMerchantPayCount(detailCountsByMerchant.getOrDefault(merchantKey,
valueOrZero(merchant.getPayCount())));
merchantRow.setMerchantTotalPrice(centsToYuan(detailPricesByMerchant.getOrDefault(merchantKey,
valueOrZero(merchant.getTotalPrice()))));
rows.add(merchantRow);
rows.addAll(detailRowsByMerchant.getOrDefault(merchantKey, List.of()));
}
}
return rows;
}
/**
* 将商品聚合行按预约当天每日菜谱中的套餐组成展开。
*
* <p>组成商品数量按“套餐预约数量 × 套餐内份数”计算;组成商品金额按菜谱单价权重使用最大余数法分摊,
* 从而保证所有组成行金额之和严格等于套餐订单快照金额,并避免极小金额因四舍五入产生负数尾差。</p>
*
* @param product 订单购物车商品聚合记录,数量和金额均为套餐商品口径
* @param menu 与商户、预约日期、餐段和商品 SPU 匹配的每日菜谱;为空时保留原商品行
* @return 单品原始行,或按套餐组成顺序生成的商品明细行
*/
private List<HardCanteenProductApptExcelVo> toProductRows(HardCanteenProductApptExportRecord product, DailyMenuAppVO menu) {
List<OnlineSpuComboItemVO> items = menu != null && menu.getComboItems() != null
&& !menu.getComboItems().isEmpty() ? menu.getComboItems() : List.of();
if (items.isEmpty()) {
return List.of(toProductRow(product.getProductName(), valueOrZero(product.getPayCount()),
product.getTotalPrice()));
}
long packageCount = valueOrZero(product.getPayCount());
long totalCents = valueOrZero(product.getTotalPrice());
long sign = totalCents < 0 ? -1L : 1L;
long absoluteTotalCents = Math.abs(totalCents);
long baseAmount = items.stream().mapToLong(item -> Math.max(0, valueOrZero(item.getPrice()))
* Math.max(1, valueOrZero(item.getQuantity()))).sum();
long[] itemPrices = new long[items.size()];
long[] itemWeights = new long[items.size()];
long allocated = 0L;
List<Integer> remainderIndexes = new ArrayList<>();
List<HardCanteenProductApptExcelVo> result = new ArrayList<>();
for (int index = 0; index < items.size(); index++) {
OnlineSpuComboItemVO item = items.get(index);
long itemWeight = Math.max(0, valueOrZero(item.getPrice())) * Math.max(1, valueOrZero(item.getQuantity()));
itemWeights[index] = itemWeight;
if (baseAmount > 0) {
BigDecimal exactPrice = BigDecimal.valueOf(absoluteTotalCents)
.multiply(BigDecimal.valueOf(itemWeight))
.divide(BigDecimal.valueOf(baseAmount), 6, RoundingMode.DOWN);
itemPrices[index] = exactPrice.setScale(0, RoundingMode.FLOOR).longValue();
allocated += itemPrices[index];
remainderIndexes.add(index);
}
}
if (baseAmount > 0) {
long remainder = absoluteTotalCents - allocated;
// 每项先向下取整,再按小数余数从大到小补分,保证分摊值不为负且合计不丢分。
remainderIndexes.sort(Comparator.comparing((Integer index) -> {
BigDecimal exactPrice = BigDecimal.valueOf(absoluteTotalCents)
.multiply(BigDecimal.valueOf(itemWeights[index]))
.divide(BigDecimal.valueOf(baseAmount), 6, RoundingMode.DOWN);
return exactPrice.subtract(BigDecimal.valueOf(itemPrices[index]));
}).reversed());
for (int index = 0; index < remainderIndexes.size() && remainder > 0; index++, remainder--) {
itemPrices[remainderIndexes.get(index)]++;
}
} else if (!items.isEmpty()) {
itemPrices[items.size() - 1] = absoluteTotalCents;
}
for (int index = 0; index < items.size(); index++) {
OnlineSpuComboItemVO item = items.get(index);
long itemCount = packageCount * Math.max(1, valueOrZero(item.getQuantity()));
long itemPrice = itemPrices[index] * sign;
result.add(toProductRow(StrUtil.blankToDefault(item.getName(), "未命名商品"), itemCount, itemPrice));
}
return result;
}
/** 创建单个商品明细行。 */
private HardCanteenProductApptExcelVo toProductRow(String name, long count, long totalPrice) {
HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo();
row.setProductName(name);
row.setProductCount(count);
row.setProductTotalPrice(centsToYuan(totalPrice));
return row;
}
/**
* 在当前商户部门的商品明细右侧写入按商品名称合并后的统计结果。
*
* <p>套餐组成商品可能由多个套餐分别展开成同名明细,因此不能按原套餐 SPU 汇总;这里使用压缩空白后的名称作为键,
* 保留首次出现的展示名称,并使用整数分累计金额,确保统计总数量、总价格与左侧有效明细严格一致。</p>
*
* @param detailRows 同一预约日期、餐段、商户和部门下,已经扣减完成退款并完成套餐展开的商品明细
* @return 独立的商品统计行,顺序与商品名称首次出现的顺序一致
*/
private List<HardCanteenProductApptExcelVo> buildProductStatistics(
List<HardCanteenProductApptExcelVo> detailRows) {
Map<String, HardCanteenProductApptExcelVo> statistics = new LinkedHashMap<>();
for (HardCanteenProductApptExcelVo detailRow : detailRows) {
String displayName = StrUtil.blankToDefault(detailRow.getProductName(), "未命名商品").trim();
String statisticKey = normalizeProductName(displayName);
HardCanteenProductApptExcelVo statistic = statistics.computeIfAbsent(statisticKey, key -> {
HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo();
row.setStatisticProductName(displayName);
row.setStatisticProductCount(0L);
row.setStatisticProductTotalPrice(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
return row;
});
statistic.setStatisticProductCount(valueOrZero(statistic.getStatisticProductCount())
+ valueOrZero(detailRow.getProductCount()));
long statisticTotalPrice = yuanToCents(statistic.getStatisticProductTotalPrice())
+ yuanToCents(detailRow.getProductTotalPrice());
statistic.setStatisticProductTotalPrice(centsToYuan(statisticTotalPrice));
}
return new ArrayList<>(statistics.values());
}
/**
* 规范化商品名称中的换行和连续空白,用于合并仅排版不同的同名套餐组成商品。
*
* @param productName 首次出现的商品展示名称
* @return 去除首尾空白并把连续空白压缩为单个空格的统计键
*/
private String normalizeProductName(String productName) {
return productName.replaceAll("\\s+", " ").trim();
}
/** 查询当前批次涉及的每日菜谱,并以商户、日期、餐段和 SPU 组成缓存键。 */
private Map<String, DailyMenuAppVO> fetchDailyMenus(List<HardCanteenProductApptExportRecord> products,
LocalDate fixedDate) {
Map<String, DailyMenuAppVO> result = new HashMap<>();
LocalDate date = fixedDate;
if (date == null) return result;
List<Long> merchantIds = products.stream().map(HardCanteenProductApptExportRecord::getMerchantId)
.filter(Objects::nonNull).distinct().toList();
for (Long merchantId : merchantIds) {
for (String hourType : products.stream().filter(item -> Objects.equals(item.getMerchantId(), merchantId))
.map(HardCanteenProductApptExportRecord::getHourType).filter(StrUtil::isNotBlank).distinct().toList()) {
try {
Integer hour = Integer.valueOf(hourType);
R<List<DailyMenuAppVO>> response = remoteDailyMenuService.getCanteenList(date.toString(), hour, null, merchantId);
if (response != null && response.isOk() && response.getData() != null) {
response.getData().forEach(menu -> {
if (menu.getSpuId() != null) result.put(merchantId + "|" + date + "|" + hourType + "|" + menu.getSpuId(), menu);
});
}
} catch (Exception exception) {
log.warn("查询每日菜谱套餐组成失败,保留原商品行,merchantId:{},date:{},hourType:{}",
merchantId, date, hourType, exception.getMessage());
}
}
}
return result;
}
/** 批量查询商户名称。 */
private Map<Long, String> fetchMerchantNames(List<HardCanteenProductApptExportRecord> rows) {
List<Long> ids = rows.stream().map(HardCanteenProductApptExportRecord::getMerchantId).filter(Objects::nonNull).distinct().toList();
if (ids.isEmpty()) return Map.of();
try {
R<List<MchManageVo>> response = remoteMchService.getByIds(ids);
if (response == null || !response.isOk() || response.getData() == null) return Map.of();
return response.getData().stream().filter(item -> item.getId() != null)
.collect(Collectors.toMap(MchManageVo::getId, MchManageVo::getName, (left, right) -> left));
} catch (Exception exception) {
log.warn("查询导出商户名称失败,使用空名称", exception);
return Map.of();
}
}
/** 批量查询部门名称。 */
private Map<Long, String> fetchDepartmentNames(List<HardCanteenProductApptExportRecord> rows) {
List<Long> ids = rows.stream().map(HardCanteenProductApptExportRecord::getUserDeptId).filter(Objects::nonNull).distinct().toList();
if (ids.isEmpty()) return Map.of();
try {
R<Map<Long, SysDept>> response = remoteDeptService.getDeptByDeptIds(ids);
if (response == null || !response.isOk() || response.getData() == null) return Map.of();
return response.getData().values().stream().filter(item -> item.getDeptId() != null)
.collect(Collectors.toMap(SysDept::getDeptId, SysDept::getName, (left, right) -> left));
} catch (Exception exception) {
log.warn("查询导出部门名称失败,使用空名称", exception);
return Map.of();
}
}
/** 查询餐段字典。 */
private Map<String, String> fetchHourNames() {
try {
R<List<SysDictItem>> response = remoteDictService.getDictByType(MEAL_SEGMENT_DICT_TYPE);
if (response == null || !response.isOk() || response.getData() == null) return Map.of();
return response.getData().stream().filter(item -> StrUtil.isNotBlank(item.getItemValue()))
.collect(Collectors.toMap(SysDictItem::getItemValue, SysDictItem::getLabel, (left, right) -> left));
} catch (Exception exception) {
log.warn("查询导出餐段字典失败,使用餐段编码", exception);
return Map.of();
}
}
/** 复制菜品统计查询条件,避免异步任务与请求对象共享可变数组和集合。 */
private HardOrderDto copyProductQuery(HardOrderDto source) {
if (source == null) throw new IllegalArgumentException("菜品预约导出查询条件不能为空");
HardOrderDto target = new HardOrderDto();
target.setApptUseDateTimeList(source.getApptUseDateTimeList() == null ? null : source.getApptUseDateTimeList().clone());
target.setHourType(source.getHourType());
target.setMerchantId(source.getMerchantId());
target.setUserDeptIds(copyList(source.getUserDeptIds()));
target.setIsInclude(source.getIsInclude());
target.setMerchantDeptIds(copyList(source.getMerchantDeptIds()));
target.setMchIds(copyList(source.getMchIds()));
target.setOrderType(HardOrderTypeEnum.RESERVE.getType());
target.setApptIdOrApptRuleIdNotNull(true);
target.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes());
return target;
}
/** 获取查询日期闭区间。 */
private List<LocalDate> getQueryDates(HardOrderDto query) {
if (query.getApptUseDateTimeList() == null || query.getApptUseDateTimeList().length != 2
|| query.getApptUseDateTimeList()[0] == null || query.getApptUseDateTimeList()[1] == null) {
throw new IllegalArgumentException("预约时间不能为空");
}
LocalDate start = query.getApptUseDateTimeList()[0].toLocalDate();
LocalDate end = query.getApptUseDateTimeList()[1].toLocalDate();
if (end.isBefore(start)) throw new IllegalArgumentException("预约结束时间不能早于开始时间");
List<LocalDate> dates = new ArrayList<>();
for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) dates.add(date);
return dates;
}
/** 构造商户部门分组键。 */
private String groupKey(HardCanteenProductApptExportRecord record) {
return record.getApptDate() + "|" + record.getHourType() + "|" + record.getMerchantId() + "|" + record.getUserDeptId();
}
/** 构造日期餐段分组键,用于从日期餐段汇总行定位其下属商户部门统计。 */
private String summaryGroupKey(HardCanteenProductApptExportRecord record) {
return record.getApptDate() + "|" + record.getHourType();
}
/** 构造商品每日菜谱缓存键。 */
private String menuKey(HardCanteenProductApptExportRecord record, LocalDate date) {
return record.getMerchantId() + "|" + date + "|" + record.getHourType() + "|" + record.getProductId();
}
/** 将空数值按零处理。 */
private long valueOrZero(Number value) {
return value == null ? 0L : value.longValue();
}
/** 将分转换为元,保持两位小数。 */
private BigDecimal centsToYuan(Number cents) {
return BigDecimal.valueOf(valueOrZero(cents)).movePointLeft(2).setScale(2, RoundingMode.HALF_UP);
}
/**
* 将商品明细展示金额从元转换回分,用于按最终商品行重新计算日期餐段和部门合计。
*
* <p>商品行已经按两位小数输出,因此这里使用半舍入恢复整数分,避免直接使用 double 导致合计出现尾差。</p>
*
* @param yuan 商品明细展示金额,单位为元;为空按零处理
* @return 金额,单位为分
*/
private long yuanToCents(BigDecimal yuan) {
return yuan == null ? 0L : yuan.movePointRight(2).setScale(0, RoundingMode.HALF_UP).longValue();
}
/** 解析餐段名称,字典缺失时保留原始编码。 */
private String resolveHourName(String hourType, Map<String, String> hourNames) {
return StrUtil.blankToDefault(hourNames.get(hourType), StrUtil.blankToDefault(hourType, "未标记餐段"));
}
/** 上传已关闭的 Excel 临时文件并提取下载地址。 */
private String upload(Path path, String fileName) throws Exception {
MultipartFile file = new MockMultipartFile("file", encodeFileName(fileName), EXCEL_CONTENT_TYPE,
Files.readAllBytes(path));
R<?> response = remoteFileService.innerUpload(file, EXPORT_FILE_DIR, null, PRODUCT_EXPORT_FILE_TYPE);
if (response == null || !response.isOk() || !(response.getData() instanceof Map<?, ?> data)
|| data.get("url") == null || StrUtil.isBlank(data.get("url").toString())) {
throw new IllegalStateException("文件中心未返回菜品预约导出下载地址");
}
return data.get("url").toString();
}
/** 按文件中心约定编码中文文件名。 */
private String encodeFileName(String fileName) {
return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
}
}
@@ -398,8 +398,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
* @param queryDates 查询范围内按升序排列的全部日期
* @return 符合用户和预约状态条件的全部行
*/
private List<HardCanteenUserApptVo> listAllFilteredRows(HardCanteenUserApptQueryDto query,
List<LocalDate> queryDates) {
private List<HardCanteenUserApptVo> listAllFilteredRows(HardCanteenUserApptQueryDto query, List<LocalDate> queryDates) {
List<UserVO> users = fetchAllUsers(query);
return filterRows(buildRows(users, query, queryDates), query);
}
@@ -536,8 +535,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
* @return 含准确总数的用户分页
*/
private Page<UserVO> fetchUserPage(long current, long size, HardCanteenUserApptQueryDto query) {
R<Page<UserVO>> response = remoteUserService.getScopeBasicUserPage(current, size,
buildUserQuery(query));
R<Page<UserVO>> response = remoteUserService.getScopeBasicUserPage(current, size, buildUserQuery(query));
if (response == null || !response.isOk() || response.getData() == null) {
String message = response == null ? null : response.getMsg();
throw new IllegalStateException(StrUtil.isBlank(message) ? "查询权限用户列表失败" : message);
@@ -91,6 +91,7 @@ import com.bsd.pjcloud.hard.mapper.HardThirdMiOrderMapper;
import com.bsd.pjcloud.hard.mapper.order.HardMessageOutboxMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.*;
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
import com.bsd.pjcloud.supermarket.api.feign.RemoteMallProductService;
import com.bsd.pjcloud.supermarket.api.feign.RemoteShopSkuService;
import com.bsd.pjcloud.supermarket.api.feign.RemoteSupermarketSpuService;
@@ -217,6 +218,8 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
private final RemoteWalletTransactionService walletTransactionService;
private final RemoteWalletSettingService walletSettingService;
private final RemoteUserService userService;
/** 硬件订单用户身份筛选和展示资料装配器,统一复用 UPMS 权限用户查询。 */
private final HardUserIdentitySupport userIdentitySupport;
private final RemoteUserRelationService userRelationService;
private final RemoteDeptService deptService;
private final RemoteMchRuleJoinService mchRuleJoinService;
@@ -244,6 +247,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
@Override
public Page<HardOrderVo> pagejoin(Page page, HardOrderDto dto) {
applyIdentityQuery(dto);
Page<HardOrderVo> list = baseMapper.pageJoin(page, dto);
disposeList(list.getRecords(), dto);
return list;
@@ -251,6 +255,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
@Override
public List<HardOrderVo> scopeList(HardOrderDto dto, DataScope dataScope) {
applyIdentityQuery(dto);
List<HardOrderVo> list = baseMapper.scopeList(dto, dataScope);
disposeList(list, dto);
return list;
@@ -305,6 +310,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
*/
@Override
public Page<HardOrderVo> scopePageList(Page page, HardOrderDto dto, DataScope dataScope) {
applyIdentityQuery(dto);
if (dto.getAppViewerUserId() != null) {
Page<HardOrderVo> list = appVisibilityPageList(page, dto, dataScope);
disposeList(list.getRecords(), dto);
@@ -351,6 +357,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
*/
@Override
public Page<HardOrderVo> noRecogScopePageJoin(Page page, HardOrderDto dto, DataScope dataScope) {
applyIdentityQuery(dto);
dto.setOrderPayTypeCodes(List.of(HardPayTypeEnum.HARD_DOOR_FREE_PAY.getType()));
dto.setOrderStatus(HardOrderStatusEnum.CREATE.getType());
Page<HardOrderVo> list = baseMapper.scopePageList(page, dto, dataScope);
@@ -371,10 +378,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
CompletableFuture<Void> findUserFuture = CompletableFuture.runAsync(() -> {
RequestContextHolder.setRequestAttributes(requestAttributes);
if (ObjectUtil.isNotEmpty(detail.getUserId())) {
List<UserVO> userVoList = userService.getUserDeptByIds(List.of(detail.getUserId())).getData();
if (ObjectUtil.isNotEmpty(userVoList) && ObjectUtil.isNotEmpty(userVoList.get(0))) {
DataUtil.convertUserDept(detail, userVoList.get(0));
}
userIdentitySupport.fillUserInfo(List.of(detail), true);
}
});
// 商户
@@ -1905,16 +1909,21 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
}
/**
* 把下单时命中的配置身份、规则和可确定截止时间写入订单。周期规则缺少有效批次时整组快照留空。
* 把下单时命中的配置身份、规则和用餐日核销结束时间写入订单。核销结束时间不受退款开关控制,
* 即使当前关闭过期退款,也要保留订单自身快照,供以后调整延迟并立即重扫时使用。
*
* @param order 尚未入库的每日菜谱订单
* @param reservation 已校验预约上下文
* @param config 本批次唯一配置查询结果;为空时不写快照
* @param config 本批次唯一配置查询结果;为空时仍保留预约时间快照,但不创建退款配置快照
*/
private void applyDailyMenuAutoRefundSnapshot(HardOrderEntity order,
HardApptReservationService.ReservationValidation reservation,
MchAutoRefundConfigVo config) {
if (config == null || reservation == null) {
if (reservation == null) {
return;
}
order.setExpiredRefundUsageEndTime(reservation.usageEndDateTime());
if (config == null) {
return;
}
MchAutoRefundRuleTypeEnum rule = MchAutoRefundRuleTypeEnum.valueOfName(config.getRuleType());
@@ -1932,11 +1941,10 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
&& config.getExpiredReserveRefundDelayHours() != null
&& config.getExpiredReserveRefundDelayHours() >= 1
&& config.getExpiredReserveRefundDelayHours() <= 168
&& reservation.usageEndDateTime() != null
&& order.getExpiredRefundUsageEndTime() != null
&& config.getId() != null) {
order.setExpiredRefundConfigId(config.getId());
order.setExpiredRefundUsageEndTime(reservation.usageEndDateTime());
order.setExpiredRefundDeadline(reservation.usageEndDateTime().plusHours(config.getExpiredReserveRefundDelayHours()));
order.setExpiredRefundDeadline(order.getExpiredRefundUsageEndTime().plusHours(config.getExpiredReserveRefundDelayHours()));
order.setExpiredRefundRestoreInventory(Objects.equals(config.getExpiredReserveRefundRestoreInventory(), YesNoEnum.YES.getCode())
? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode());
}
@@ -2464,6 +2472,8 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
.walletId(dinerWallet.getId())
.isOfflineOrder(YesNoEnum.NO.getCode())
.apptUseDate(reservation.apptDate())
// 餐段配置修改频繁,必须在预约校验通过的下单时刻固化,后续重扫不得按当前配置反推历史订单。
.expiredRefundUsageEndTime(reservation.usageEndDateTime())
.productDesc(subject)
.build();
order.setHourType(reservation.hourType());
@@ -4839,20 +4849,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
// 用户部门
CompletableFuture<Void> deptFuture = CompletableFuture.runAsync(() -> {
RequestContextHolder.setRequestAttributes(requestAttributes);
List<Long> userIds = list.stream().map(HardOrderVo::getUserId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
List<UserVO> userList = userService.getUserDeptByIds(userIds).getData();
if (ObjectUtil.isNotEmpty(userList)) {
Map<Long, UserVO> map = CollectionUtils.convertMap(userList, UserVO::getUserId);
for (HardOrderVo item : list) {
UserVO userVO = map.get(item.getUserId());
if (ObjectUtil.isEmpty(userVO)) continue;
item.setUsername(userVO.getName());
item.setUserMobile(userVO.getPhone());
item.setUserAvatar(userVO.getAvatar());
}
}
}
userIdentitySupport.fillUserInfo(list, true);
});
// 当前部门
CompletableFuture<Void> currentDeptFuture = CompletableFuture.runAsync(() -> {
@@ -4935,4 +4932,21 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
});
CompletableFuture.allOf(deptFuture, mchFuture, currentDeptFuture, walletFuture, findCarListFuture, findApptListFuture).join();
}
/**
* 将身份条件预筛选为用户主键,保持订单 SQL 的原有用户条件和数据范围语义。
*
* @param dto 订单查询条件
*/
private void applyIdentityQuery(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);
}
}
@@ -149,7 +149,8 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
HardOrderDto totalDto = BeanUtil.toBean(dto, HardOrderDto.class);
totalDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice());
DataScope totalDataScope = null;
if (ObjectUtil.isNotEmpty(dataScope)) {
if (dataScope != null) {
// DataScope 继承 HashMap,空对象仍包含数据权限配置,不能按 Map 非空判断丢弃权限范围。
totalDataScope = BeanUtils.toBean(dataScope, DataScope.class).func(DataScopeFuncEnum.ALL);
totalDataScope.setScopeDeptName("merchant_dept_id");
}
@@ -161,7 +162,8 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
refundDto.setRefundStatusCodes(List.of(HardSaleStatusEnum.COMPLETE.getStatus()));
refundDto.setOrderStatusCodes(HardOrderStatusEnum.getPayErrorCodes());
DataScope refundDataScope = null;
if (ObjectUtil.isNotEmpty(dataScope)) {
if (dataScope != null) {
// 退款汇总与支付汇总必须使用同一份租户及部门数据权限快照。
refundDataScope = BeanUtils.toBean(dataScope, DataScope.class).func(DataScopeFuncEnum.ALL);
refundDataScope.setScopeDeptName("merchant_dept_id");
}
@@ -4,11 +4,7 @@ import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.admin.api.utils.DataUtil;
import com.bsd.pjcloud.common.core.util.CollectionUtils;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.hard.api.constant.spu.HardSpuRepertoryTypeEnum;
@@ -23,6 +19,7 @@ import com.bsd.pjcloud.hard.api.vo.spu.HardSpuRepertoryVo;
import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
import com.bsd.pjcloud.hard.mapper.spu.HardSpuRepertoryMapper;
import com.bsd.pjcloud.hard.service.*;
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.utils.WalletDataUtil;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
@@ -50,10 +47,20 @@ public class HardSpuRepertoryServiceImpl extends ServiceImpl<HardSpuRepertoryMap
private final HardSpuJoinService hardSpuJoinService;
private final RemoteMchService remoteMchService;
private final RemoteUserService remoteUserService;
/** 库存上架记录用户身份筛选与展示资料装配器。 */
private final HardUserIdentitySupport userIdentitySupport;
/**
* 分页查询库存更新记录,并补齐上架用户身份及关联展示信息。
*
* @param page 分页参数
* @param dto 库存记录、用户身份及用户范围筛选条件
* @param dataScope 当前管理人员的数据范围
* @return 已完成用户、商户和硬件展示资料装配的分页结果
*/
@Override
public Page<HardSpuRepertoryVo> scopePageJoin(Page page, HardSpuRepertoryDto dto, DataScope dataScope) {
prepareIdentityQuery(dto);
Page<HardSpuRepertoryVo> pageList = baseMapper.scopePageJoin(page, dto, dataScope);
disposeList(pageList.getRecords(), dto);
return pageList;
@@ -235,6 +242,12 @@ public class HardSpuRepertoryServiceImpl extends ServiceImpl<HardSpuRepertoryMap
return false;
}
/**
* 批量补齐库存记录的商户及用户展示信息。
*
* @param list 当前页库存更新记录;空列表不触发远程查询
* @param dto 当前查询条件,保留参数以兼容原装配方法契约
*/
private void disposeList(List<HardSpuRepertoryVo> list, HardSpuRepertoryDto dto) {
if (ObjectUtil.isEmpty(list)) return;
// 商户
@@ -246,11 +259,24 @@ public class HardSpuRepertoryServiceImpl extends ServiceImpl<HardSpuRepertoryMap
WalletDataUtil.convertMchList(list, CollectionUtils.convertMap(mchList, MchManageVo::getId));
}
}
// 用户
List<Long> userIds = list.stream().map(HardSpuRepertoryVo::getUserId).distinct().toList();
R<List<SysUser>> userList = remoteUserService.getUserById(new ArrayList<>(userIds));
if (ObjectUtil.isNotEmpty(userList.getData())) {
DataUtil.convertUserList(list, CollectionUtils.convertMap(userList.getData(), SysUser::getUserId));
}
// 用户身份、姓名、手机号、头像及部门统一由 UPMS 公共装配器回填。
userIdentitySupport.fillUserInfo(list, true);
}
}
/**
* 将库存上架记录的身份条件转换为最终用户主键集合,保持原有 userIds SQL 过滤契约。
*
* @param dto 库存记录查询条件;处理后身份字段被最终用户主键集合替代
*/
private void prepareIdentityQuery(HardSpuRepertoryDto 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);
}
}
@@ -0,0 +1,255 @@
package com.bsd.pjcloud.hard.support;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
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.feign.RemoteUserService;
import com.bsd.pjcloud.admin.api.vo.UserVO;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.common.core.util.CollectionUtils;
import com.bsd.pjcloud.common.core.util.R;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Hardware 后台列表的用户身份查询与展示资料装配器。
*
* <p>订单等业务表只保存用户主键和必要快照,身份筛选统一交给 UPMS 数据权限用户接口处理,
* Hardware 仅将命中的用户主键并入原有查询,并为当前页批量回填身份展示资料。</p>
*/
@Component
@RequiredArgsConstructor
public class HardUserIdentitySupport {
/** 单次调用 UPMS 查询的最大用户数量,控制跨服务响应体和内存占用。 */
private static final long USER_BATCH_SIZE = 500L;
/** 身份条件无匹配用户时使用的无效用户主键,确保下游忽略空集合的 SQL 不会放宽查询。 */
private static final Long NO_MATCH_USER_ID = -1L;
/** UPMS 用户远程服务,负责数据权限、身份筛选及身份资料返回。 */
private final RemoteUserService remoteUserService;
/**
* 判断查询对象是否携带实际生效的身份或人脸条件。
*
* @param query Hardware 业务查询对象;为空表示没有身份条件
* @return 至少存在一个有效身份、人脸或身份扩展条件时返回 {@code true}
*/
public boolean hasIdentityQuery(BaseUserIdentityQueryInterface query) {
if (query == null) {
return false;
}
return query.getIdentityTypes() != null && !query.getIdentityTypes().isEmpty()
|| hasStudentQuery(query.getStudentInfo())
|| hasTeacherQuery(query.getTeacherInfo())
|| StrUtil.isNotBlank(query.getHasFace());
}
/**
* 在现有用户条件范围内查询符合身份条件的用户主键。
*
* @param query 身份及人脸查询条件
* @param userId 页面已有的单用户筛选条件
* @param userIds 页面已有的多用户筛选条件
* @return 同时符合已有用户范围、当前操作者数据权限和身份条件的用户主键;无匹配时返回无效主键
*/
public List<Long> findIdentityUserIds(BaseUserIdentityQueryInterface query, Long userId, List<Long> userIds) {
List<Long> constrainedUserIds = mergeUserConstraints(userId, userIds);
if (constrainedUserIds != null && constrainedUserIds.isEmpty()) {
return List.of(NO_MATCH_USER_ID);
}
UserDTO userQuery = buildUserQuery(query, constrainedUserIds);
List<UserVO> users = fetchAllUsers(userQuery);
if (users.isEmpty()) {
return List.of(NO_MATCH_USER_ID);
}
return users.stream().map(UserVO::getUserId).filter(Objects::nonNull).distinct().toList();
}
/**
* 批量补齐 Hardware 列表的用户主资料和身份资料,并按需更新当前用户部门。
*
* @param rows 当前页 Hardware 业务记录
* @param updateDepartment 是否使用 UPMS 当前部门覆盖业务记录中的部门快照
* @param <T> 同时实现用户部门和用户身份展示契约的业务 VO 类型
*/
public <T extends BaseUserDeptInterface & BaseUserIdentityInterface> void fillUserInfo(List<T> rows,
boolean updateDepartment) {
if (rows == null || rows.isEmpty()) {
return;
}
List<Long> userIds = rows.stream().map(BaseUserDeptInterface::getUserId).filter(Objects::nonNull).distinct().toList();
if (userIds.isEmpty()) {
return;
}
UserDTO userQuery = new UserDTO();
userQuery.setUserIds(userIds);
Map<Long, UserVO> userMap = CollectionUtils.convertMap(fetchAllUsers(userQuery), UserVO::getUserId);
for (T row : rows) {
UserVO user = userMap.get(row.getUserId());
if (user == null) {
continue;
}
// 部分历史 VO 显式声明了标准字段,需同时回填旧字段以兼容既有接口与导出。
row.setUsername(user.getName());
row.setUserMobile(user.getPhone());
row.setUserAvatar(user.getAvatar());
row.setName(user.getName());
row.setPhone(user.getPhone());
row.setAvatar(user.getAvatar());
if (updateDepartment) {
row.setUserDeptId(user.getDeptId());
row.setUserDeptName(user.getDeptName());
row.setDeptName(user.getDeptName());
}
row.setIdentityTypes(copyList(user.getIdentityTypes()));
row.setStudentInfo(copyStudentInfo(user.getStudentInfo()));
row.setTeacherInfo(copyTeacherInfo(user.getTeacherInfo()));
}
}
/**
* 合并单用户和多用户条件;两者同时存在时取交集,保持原查询的“同时满足”语义。
*
* @param userId 单用户主键
* @param userIds 多用户主键集合
* @return 去重后的用户限制;返回 {@code null} 表示未限制用户,空集合表示条件互斥
*/
private List<Long> mergeUserConstraints(Long userId, List<Long> userIds) {
LinkedHashSet<Long> constraints = new LinkedHashSet<>();
if (userIds != null && !userIds.isEmpty()) {
userIds.stream().filter(Objects::nonNull).forEach(constraints::add);
}
if (userId == null) {
return constraints.isEmpty() ? null : new ArrayList<>(constraints);
}
if (constraints.isEmpty()) {
return List.of(userId);
}
return constraints.contains(userId) ? List.of(userId) : List.of();
}
/**
* 把 Hardware 查询契约转换为 UPMS 用户查询对象,并复制可变身份条件。
*
* @param query Hardware 侧身份查询条件
* @param constrainedUserIds 已有用户筛选范围
* @return 可独立交给 UPMS 使用的用户查询对象
*/
private UserDTO buildUserQuery(BaseUserIdentityQueryInterface query, List<Long> constrainedUserIds) {
UserDTO userQuery = new UserDTO();
userQuery.setIdentityTypes(copyList(query.getIdentityTypes()));
userQuery.setStudentInfo(copyStudentInfo(query.getStudentInfo()));
userQuery.setTeacherInfo(copyTeacherInfo(query.getTeacherInfo()));
userQuery.setHasFace(query.getHasFace());
userQuery.setUserIds(copyList(constrainedUserIds));
return userQuery;
}
/**
* 分页读取当前操作者权限范围内的全部匹配用户。
*
* @param query UPMS 用户查询条件
* @return 按 UPMS 分页顺序合并的用户列表
*/
private List<UserVO> fetchAllUsers(UserDTO query) {
List<UserVO> users = new ArrayList<>();
long current = 1L;
long total;
do {
R<Page<UserVO>> response = remoteUserService.getScopeBasicUserPage(current, USER_BATCH_SIZE, query);
if (response == null || !response.isOk() || response.getData() == null) {
String message = response == null ? null : response.getMsg();
throw new IllegalStateException(StrUtil.blankToDefault(message, "查询权限用户及身份信息失败"));
}
Page<UserVO> page = response.getData();
List<UserVO> records = page.getRecords() == null ? List.of() : page.getRecords();
users.addAll(records);
total = page.getTotal();
if (records.isEmpty()) {
break;
}
current++;
} while (users.size() < total);
return users;
}
/**
* 判断学生扩展对象是否包含有效筛选字段。
*
* @param studentInfo 学生身份扩展条件
* @return 学号、学校、班级或监护人手机号任一非空时返回 {@code true}
*/
private boolean hasStudentQuery(SysUserStudentDto studentInfo) {
return studentInfo != null && (StrUtil.isNotBlank(studentInfo.getStudentNo())
|| StrUtil.isNotBlank(studentInfo.getSchoolName())
|| StrUtil.isNotBlank(studentInfo.getClassName())
|| StrUtil.isNotBlank(studentInfo.getGuardianPhones()));
}
/**
* 判断老师扩展对象是否包含有效筛选字段。
*
* @param teacherInfo 老师身份扩展条件
* @return 老师学号非空时返回 {@code true}
*/
private boolean hasTeacherQuery(SysUserTeacherDto teacherInfo) {
return teacherInfo != null && StrUtil.isNotBlank(teacherInfo.getTeacherNo());
}
/**
* 复制学生身份资料,避免跨服务响应对象被业务列表意外修改。
*
* @param source 原学生身份资料
* @return 独立副本;源对象为空时返回 {@code null}
*/
private SysUserStudentDto copyStudentInfo(SysUserStudentDto source) {
if (source == null) {
return null;
}
SysUserStudentDto target = new SysUserStudentDto();
target.setStudentNo(source.getStudentNo());
target.setSchoolName(source.getSchoolName());
target.setClassName(source.getClassName());
target.setGuardianPhones(source.getGuardianPhones());
target.setExtJson(source.getExtJson());
return target;
}
/**
* 复制老师身份资料,保持查询与展示对象相互独立。
*
* @param source 原老师身份资料
* @return 独立副本;源对象为空时返回 {@code null}
*/
private SysUserTeacherDto copyTeacherInfo(SysUserTeacherDto source) {
if (source == null) {
return null;
}
SysUserTeacherDto target = new SysUserTeacherDto();
target.setTeacherNo(source.getTeacherNo());
return target;
}
/**
* 复制可变列表并保持空值语义。
*
* @param source 原列表
* @param <T> 列表元素类型
* @return 独立列表;源列表为空值时返回 {@code null}
*/
private <T> List<T> copyList(List<T> source) {
return source == null ? null : new ArrayList<>(source);
}
}
@@ -41,6 +41,12 @@
<if test="dto.userId != null">
and a.user_id = #{dto.userId}
</if>
<if test="dto.userIds != null and dto.userIds.size() != 0">
and a.user_id in
<foreach collection="dto.userIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.hardId != null">
and a.hard_id = #{dto.hardId}
</if>
@@ -70,4 +76,4 @@
</where>
order by a.create_time desc , a.id desc
</select>
</mapper>
</mapper>
@@ -51,6 +51,12 @@
<if test="dto.userId != null">
and a.user_id = #{dto.userId}
</if>
<if test="dto.userIds != null and dto.userIds.size() != 0">
and a.user_id in
<foreach collection="dto.userIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.hardId != null">
and a.hard_id = #{dto.hardId}
</if>
@@ -87,4 +93,4 @@
</where>
order by a.create_time desc, a.id desc
</select>
</mapper>
</mapper>
@@ -84,4 +84,4 @@
</where>
order by a.create_time desc, a.id desc
</select>
</mapper>
</mapper>
@@ -101,4 +101,4 @@
</where>
order by a.create_time desc,a.id desc
</select>
</mapper>
</mapper>
@@ -68,4 +68,38 @@
</if>
order by t.trigger_time asc, t.id asc
</select>
<!--
立即重扫只读取仍可进入整单退款流程的每日菜谱钱包订单。
已存在处理中或已完成售后的订单由原售后和任务状态机继续处理,本接口不重置。
-->
<select id="listExpiredRescanOrders"
resultType="com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity">
select o.*
from hard_order o
inner join hard_parent_car p
on p.id = o.parent_car_id
and p.del_flag = '0'
where o.del_flag = '0'
and o.merchant_id = #{mchId}
and o.order_type = '2'
and o.sys_type = '2'
and o.order_status = '5'
and o.pay_type = '1'
and o.pay_price &gt; 0
and (o.refund_price is null or o.refund_price = 0)
and case
when json_valid(p.take_out_setting_json) = 1
then json_unquote(json_extract(p.take_out_setting_json, '$.cartScene'))
else null
end = 'DAILY_MENU'
and not exists (
select 1
from hard_after_sale s
where s.order_id = o.id
and s.del_flag = '0'
and s.status in ('10', '50')
)
order by o.id asc
</select>
</mapper>
@@ -57,6 +57,21 @@
</if>
</sql>
<!-- 已完成售后按父购物车和商品聚合,供预约商品统计复用同一退款扣减口径。 -->
<sql id="completedRefundCarTotals">
(
select refund_item.parent_car_id,
refund_item.product_id,
sum(ifnull(refund_item.product_count, 0)) as product_count,
sum(ifnull(refund_item.total_price, 0)) as total_price
from hard_after_sale_car refund_item
inner join hard_after_sale refund_order on refund_order.id = refund_item.refund_id
and refund_order.del_flag = '0' and refund_order.status = '50'
where refund_item.del_flag = '0'
group by refund_item.parent_car_id, refund_item.product_id
) refund_car
</sql>
<!--
预约菜品统计统一使用退款扣减后的商品快照:先按父购物车和商品汇总原购买量,再扣除已完成(50)
售后占用的数量、金额。退款申请中(10)仍属于有效预约,部分退款完成后只减少对应份数,取消或拒绝的售后仍正常统计;
@@ -86,17 +101,8 @@
where c.del_flag = '0'
group by c.parent_car_id, c.product_id
) original_car
left join (
select refund_item.parent_car_id,
refund_item.product_id,
sum(ifnull(refund_item.product_count, 0)) as product_count,
sum(ifnull(refund_item.total_price, 0)) as total_price
from hard_after_sale_car refund_item
inner join hard_after_sale refund_order on refund_order.id = refund_item.refund_id
and refund_order.del_flag = '0' and refund_order.status = '50'
where refund_item.del_flag = '0'
group by refund_item.parent_car_id, refund_item.product_id
) refund_car on refund_car.parent_car_id = original_car.parent_car_id
left join <include refid="completedRefundCarTotals"/>
on refund_car.parent_car_id = original_car.parent_car_id
and refund_car.product_id = original_car.product_id
where original_car.product_count > ifnull(refund_car.product_count, 0)
) a
@@ -112,12 +118,98 @@
</choose>
</sql>
<!--
来源分支可以安全下推的公共订单筛选。日期条件在各分支分别使用支付时间或核销时间,外层仍保留
同一组条件作为契约保护;这里先缩小订单和购物车连接范围,避免大日期表在派生表中无条件聚合。
-->
<sql id="canteenSaleSourceOrderPushdownWhere">
<if test="dto.merchantId != null">
and o.merchant_id = #{dto.merchantId}
</if>
<if test="dto.mchIds != null and dto.mchIds.size() != 0">
and o.merchant_id in
<foreach collection="dto.mchIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.hourType != null and dto.hourType != ''">
and o.hour_type = #{dto.hourType}
</if>
<if test="dto.deptIds != null and dto.deptIds.size() != 0">
<choose>
<when test="dto.isInclude != null and dto.isInclude == true">
and o.dept_id in
</when>
<otherwise>
and o.dept_id not in
</otherwise>
</choose>
<foreach collection="dto.deptIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.userDeptIds != null and dto.userDeptIds.size() != 0">
<choose>
<when test="dto.isInclude != null and dto.isInclude == true">
and o.user_dept_id in
</when>
<otherwise>
and o.user_dept_id not in
</otherwise>
</choose>
<foreach collection="dto.userDeptIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.userIds != null and dto.userIds.size() != 0">
and o.user_id in
<foreach collection="dto.userIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
</sql>
<!-- 商品名称只能在已连接购物车别名 a 的查询中下推,避免订单型退款子查询引用不存在的字段。 -->
<sql id="canteenSaleSourceProductPushdownWhere">
<if test="dto.productName != null and dto.productName != ''">
and a.product_name like concat('%', #{dto.productName}, '%')
</if>
</sql>
<!--
预约退款只聚合当前核销统计范围内订单涉及的父购物车,避免每次统计都扫描并汇总全部历史售后。
商品名称条件不在此应用,退款最终仍按父购物车和商品主键精确关联到已筛选的原购物车行。
-->
<sql id="canteenSaleReservationRefundTotals">
(
select refund_item.parent_car_id,
refund_item.product_id,
sum(ifnull(refund_item.product_count, 0)) as product_count,
sum(ifnull(refund_item.total_price, 0)) as total_price
from hard_after_sale_car refund_item
inner join hard_after_sale refund_order on refund_order.id = refund_item.refund_id
and refund_order.del_flag = '0' and refund_order.status = '50'
inner join hard_order o on o.parent_car_id = refund_item.parent_car_id
and o.del_flag = '0' and o.merchant_type = '1' and o.order_type = '2'
and (o.appt_id is not null or o.appt_rule_id is not null)
and o.order_status = '20' and o.verification_time is not null
and o.hour_type is not null and o.hour_type not in ('-1')
where refund_item.del_flag = '0'
<if test="dto.statisticsDateTimeList != null and dto.statisticsDateTimeList.length == 2">
and o.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
</if>
<include refid="canteenSaleSourceOrderPushdownWhere"/>
group by refund_item.parent_car_id, refund_item.product_id
) refund_car
</sql>
<!--
新版食堂销售统计按业务发生时间拆分来源:线下订单使用支付时间,预约订单只使用已核销订单的核销时间。
预约分支沿用退款扣减后的商品快照;线下分支保留原始购物车快照,避免改变历史线下统计口径。
单来源查询只生成对应分支;预约分支先按已核销订单过滤再聚合购物车并扣减退款,避免全量购物车聚合。
-->
<sql id="canteenSaleStatsSource">
(
<trim prefix="(" suffix=") sale_stats" suffixOverrides="union all">
<if test="dto.saleSourceCodes == null or dto.saleSourceCodes.size() == 0 or dto.saleSourceCodes.contains('OFFLINE_PURCHASE')">
select DATE_FORMAT(o.pay_time, '%Y-%m-%d') as stat_date,
o.pay_time as stat_time,
o.id as order_id,
@@ -142,7 +234,14 @@
where a.del_flag = '0'
and o.pay_time is not null
and o.order_status in ('5', '20')
<if test="dto.statisticsDateTimeList != null and dto.statisticsDateTimeList.length == 2">
and o.pay_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
</if>
<include refid="canteenSaleSourceOrderPushdownWhere"/>
<include refid="canteenSaleSourceProductPushdownWhere"/>
union all
</if>
<if test="dto.saleSourceCodes == null or dto.saleSourceCodes.size() == 0 or dto.saleSourceCodes.contains('RESERVATION_VERIFIED')">
select DATE_FORMAT(o.verification_time, '%Y-%m-%d') as stat_date,
o.verification_time as stat_time,
o.id as order_id,
@@ -153,13 +252,13 @@
o.merchant_dept_id,
o.hour_type,
a.product_id,
a.product_name,
a.product_pic,
a.product_price,
a.product_count,
a.total_price,
max(a.product_name) as product_name,
max(a.product_pic) as product_pic,
max(a.product_price) as product_price,
greatest(sum(ifnull(a.product_count, 0)) - ifnull(refund_car.product_count, 0), 0) as product_count,
greatest(sum(ifnull(a.total_price, 0)) - ifnull(refund_car.total_price, 0), 0) as total_price,
'RESERVATION_VERIFIED' as sale_source
from <include refid="apptEffectiveCarSource"/>
from hard_car a
inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0'
and o.merchant_type = '1' and o.order_type = '2'
@@ -167,7 +266,20 @@
and o.order_status = '20'
and o.verification_time is not null
and o.hour_type is not null and o.hour_type not in ('-1')
) sale_stats
left join <include refid="canteenSaleReservationRefundTotals"/>
on refund_car.parent_car_id = a.parent_car_id
and refund_car.product_id = a.product_id
where a.del_flag = '0'
<if test="dto.statisticsDateTimeList != null and dto.statisticsDateTimeList.length == 2">
and o.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
</if>
<include refid="canteenSaleSourceOrderPushdownWhere"/>
<include refid="canteenSaleSourceProductPushdownWhere"/>
group by o.id, o.user_id, o.merchant_id, o.user_dept_id, o.dept_id, o.merchant_dept_id,
o.hour_type, o.verification_time, a.product_id, refund_car.product_count, refund_car.total_price
having sum(ifnull(a.product_count, 0)) > ifnull(refund_car.product_count, 0)
</if>
</trim>
</sql>
<!-- 新版统计统一在来源子查询外应用筛选,保证支付时间和核销时间使用同一日期控件。 -->
@@ -644,22 +756,32 @@
综合菜品销售导出为每个日期餐段同时生成跨来源合计和来源拆分。
三层 SQL 使用相同的来源、业务时间、食堂和部门筛选,保证页面与导出以及上下层之间口径一致。
-->
<select id="listSalesExportSummary"
<select id="listSalesExportRows"
resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord">
with sale_stats as (
select * from <include refid="canteenSaleStatsSource"/>
)
select export_rows.statisticsDate,
select export_rows.recordLevel,
export_rows.statisticsDate,
export_rows.hourType,
export_rows.saleSource,
export_rows.merchantId,
export_rows.userDeptId,
export_rows.productId,
export_rows.productName,
export_rows.payUserCount,
export_rows.timeCount,
export_rows.payCount,
export_rows.totalPrice
from (
select sale_stats.stat_date as statisticsDate,
select 'SUMMARY' as recordLevel,
sale_stats.stat_date as statisticsDate,
sale_stats.hour_type as hourType,
null as saleSource,
null as merchantId,
null as userDeptId,
null as productId,
null as productName,
count(distinct sale_stats.user_id) as payUserCount,
count(distinct sale_stats.order_id) as timeCount,
sum(sale_stats.product_count) as payCount,
@@ -670,9 +792,14 @@
</where>
group by sale_stats.stat_date, sale_stats.hour_type
union all
select sale_stats.stat_date as statisticsDate,
select 'SUMMARY' as recordLevel,
sale_stats.stat_date as statisticsDate,
sale_stats.hour_type as hourType,
sale_stats.sale_source as saleSource,
null as merchantId,
null as userDeptId,
null as productId,
null as productName,
count(distinct sale_stats.user_id) as payUserCount,
count(distinct sale_stats.order_id) as timeCount,
sum(sale_stats.product_count) as payCount,
@@ -682,33 +809,15 @@
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.sale_source
) export_rows
order by export_rows.statisticsDate,
export_rows.hourType,
case when export_rows.saleSource is null then 0
when export_rows.saleSource = 'OFFLINE_PURCHASE' then 1 else 2 end
</select>
<select id="listSalesExportMerchant"
resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord">
with sale_stats as (
select * from <include refid="canteenSaleStatsSource"/>
)
select export_rows.statisticsDate,
export_rows.hourType,
export_rows.saleSource,
export_rows.merchantId,
export_rows.userDeptId,
export_rows.payUserCount,
export_rows.timeCount,
export_rows.payCount,
export_rows.totalPrice
from (
select sale_stats.stat_date as statisticsDate,
union all
select 'MERCHANT' as recordLevel,
sale_stats.stat_date as statisticsDate,
sale_stats.hour_type as hourType,
null as saleSource,
sale_stats.mch_id as merchantId,
sale_stats.user_dept_id as userDeptId,
null as productId,
null as productName,
count(distinct sale_stats.user_id) as payUserCount,
count(distinct sale_stats.order_id) as timeCount,
sum(sale_stats.product_count) as payCount,
@@ -719,11 +828,14 @@
</where>
group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.mch_id, sale_stats.user_dept_id
union all
select sale_stats.stat_date as statisticsDate,
select 'MERCHANT' as recordLevel,
sale_stats.stat_date as statisticsDate,
sale_stats.hour_type as hourType,
sale_stats.sale_source as saleSource,
sale_stats.mch_id as merchantId,
sale_stats.user_dept_id as userDeptId,
null as productId,
null as productName,
count(distinct sale_stats.user_id) as payUserCount,
count(distinct sale_stats.order_id) as timeCount,
sum(sale_stats.product_count) as payCount,
@@ -734,37 +846,17 @@
</where>
group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.sale_source,
sale_stats.mch_id, sale_stats.user_dept_id
) export_rows
order by export_rows.statisticsDate,
export_rows.hourType,
case when export_rows.saleSource is null then 0
when export_rows.saleSource = 'OFFLINE_PURCHASE' then 1 else 2 end,
export_rows.merchantId,
export_rows.userDeptId
</select>
<select id="listSalesExportProduct"
resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord">
with sale_stats as (
select * from <include refid="canteenSaleStatsSource"/>
)
select export_rows.statisticsDate,
export_rows.hourType,
export_rows.saleSource,
export_rows.merchantId,
export_rows.userDeptId,
export_rows.productId,
export_rows.productName,
export_rows.payCount,
export_rows.totalPrice
from (
select sale_stats.stat_date as statisticsDate,
union all
select 'PRODUCT' as recordLevel,
sale_stats.stat_date as statisticsDate,
sale_stats.hour_type as hourType,
null as saleSource,
sale_stats.mch_id as merchantId,
sale_stats.user_dept_id as userDeptId,
sale_stats.product_id as productId,
sale_stats.product_name as productName,
null as payUserCount,
null as timeCount,
sum(sale_stats.product_count) as payCount,
sum(sale_stats.total_price) as totalPrice
from sale_stats
@@ -774,13 +866,16 @@
group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.mch_id, sale_stats.user_dept_id,
sale_stats.product_id, sale_stats.product_name
union all
select sale_stats.stat_date as statisticsDate,
select 'PRODUCT' as recordLevel,
sale_stats.stat_date as statisticsDate,
sale_stats.hour_type as hourType,
sale_stats.sale_source as saleSource,
sale_stats.mch_id as merchantId,
sale_stats.user_dept_id as userDeptId,
sale_stats.product_id as productId,
sale_stats.product_name as productName,
null as payUserCount,
null as timeCount,
sum(sale_stats.product_count) as payCount,
sum(sale_stats.total_price) as totalPrice
from sale_stats
@@ -794,6 +889,11 @@
export_rows.hourType,
case when export_rows.saleSource is null then 0
when export_rows.saleSource = 'OFFLINE_PURCHASE' then 1 else 2 end,
case export_rows.recordLevel
when 'SUMMARY' then 0
when 'MERCHANT' then 1
else 2
end,
export_rows.merchantId,
export_rows.userDeptId,
export_rows.productName
@@ -834,7 +934,7 @@
</if>
</where>
group by a.product_id
order by SUM(a.product_count) desc
order by a.product_id desc, o.order_type asc, o.hour_type asc, SUM(a.product_count) desc
</select>
<!-- 新版菜品明细按来源查询,供主表“菜品详情”弹窗使用。 -->
@@ -981,6 +1081,12 @@
#{v}
</foreach>
</if>
<if test="dto.userIds != null and dto.userIds.size() != 0">
and o.user_id in
<foreach collection="dto.userIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.payTimeList != null and dto.payTimeList.length == 2">
and o.pay_time between #{dto.payTimeList[0]} and #{dto.payTimeList[1]}
</if>
@@ -1088,6 +1194,12 @@
#{v}
</foreach>
</if>
<if test="dto.userIds != null and dto.userIds.size() != 0">
and o.user_id in
<foreach collection="dto.userIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.orderStatusCodes != null and dto.orderStatusCodes.size() != 0">
and o.order_status in
<foreach collection="dto.orderStatusCodes" item="v" open="(" close=")" separator=",">
@@ -1111,6 +1223,7 @@
select * from <include refid="canteenSaleStatsSource"/>
)
select statistics_rows.createTime,
statistics_rows.mchId,
statistics_rows.userDeptId,
statistics_rows.hourType,
statistics_rows.saleSource,
@@ -1121,6 +1234,7 @@
statistics_rows.statisticsLevel
from (
select sale_stats.stat_date as createTime,
sale_stats.mch_id as mchId,
sale_stats.user_dept_id as userDeptId,
sale_stats.hour_type as hourType,
null as saleSource,
@@ -1133,9 +1247,10 @@
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.user_dept_id, sale_stats.hour_type
group by sale_stats.stat_date, sale_stats.mch_id, sale_stats.user_dept_id, sale_stats.hour_type
union all
select sale_stats.stat_date as createTime,
sale_stats.mch_id as mchId,
sale_stats.user_dept_id as userDeptId,
sale_stats.hour_type as hourType,
sale_stats.sale_source as saleSource,
@@ -1148,9 +1263,11 @@
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.user_dept_id, sale_stats.hour_type, sale_stats.sale_source
group by sale_stats.stat_date, sale_stats.mch_id, sale_stats.user_dept_id,
sale_stats.hour_type, sale_stats.sale_source
) statistics_rows
order by statistics_rows.createTime,
statistics_rows.mchId,
statistics_rows.userDeptId,
statistics_rows.hourType,
case statistics_rows.statisticsLevel
@@ -167,6 +167,13 @@
#{v}
</foreach>
</if>
<!-- 订单列表勾选导出使用真实订单主键,避免历史 ids 参数未映射而退化为导出全部结果。 -->
<if test="dto.orderIds != null and dto.orderIds.size() != 0">
and a.id in
<foreach collection="dto.orderIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.mchIds != null and dto.mchIds.size() != 0">
and a.merchant_id in
<foreach collection="dto.mchIds" item="v" open="(" close=")" separator=",">
@@ -362,11 +369,15 @@
<when test="dto.verificationTimeList != null and dto.verificationTimeList.length == 2">
order by a.verification_time desc, a.id desc
</when>
<when test="dto.apptUseDateTimeList != null and dto.apptUseDateTimeList.length == 2">
order by a.appt_use_date desc, a.hour_type asc, a.id desc
</when>
<when test="dto.updateTimeList != null and dto.updateTimeList.length == 2">
order by a.update_time desc, a.id desc
</when>
<otherwise>
order by a.create_time desc, a.user_id desc
<!-- id 作为唯一兜底排序,保证异步导出跨页读取时不会因相同时间和用户出现重复或遗漏。 -->
order by a.create_time desc, a.user_id desc, a.id desc
</otherwise>
</choose>
</sql>
@@ -388,6 +399,9 @@
<when test="dto.verificationTimeList != null and dto.verificationTimeList.length == 2">
order by a.verification_time desc, a.id desc
</when>
<when test="dto.apptUseDateTimeList != null and dto.apptUseDateTimeList.length == 2">
order by a.appt_use_date desc, a.hour_type asc, a.id desc
</when>
<when test="dto.updateTimeList != null and dto.updateTimeList.length == 2">
order by a.update_time desc, a.id desc
</when>
@@ -24,7 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 菜品预约导出合并处理器测试,覆盖 Easy Excel 流式窗口刷盘后的早期行合并。
* 菜品销售导出合并处理器测试,覆盖 Easy Excel 流式窗口刷盘后的早期行合并。
*/
class HardCanteenProductApptExportMergeHandlerTest {
@@ -53,34 +53,35 @@ class HardCanteenProductApptExportMergeHandlerTest {
EasyExcel.write(output.toFile(), HardCanteenProductApptExcelVo.class)
.registerWriteHandler(new HardCanteenProductApptExportMergeHandler())
.sheet("菜品预约统计").doWrite(rows);
.sheet("菜品销售统计").doWrite(rows);
try (InputStream input = Files.newInputStream(output); Workbook workbook = new XSSFWorkbook(input)) {
Set<String> mergedRegions = workbook.getSheetAt(0).getMergedRegions().stream()
.map(region -> region.formatAsString()).collect(Collectors.toSet());
assertTrue(mergedRegions.contains("A3:A135"));
assertTrue(mergedRegions.contains("F3:F135"));
assertTrue(mergedRegions.contains("G4:G135"));
assertTrue(mergedRegions.contains("N4:N135"));
assertTrue(mergedRegions.contains("G3:G135"));
assertTrue(mergedRegions.contains("H4:H135"));
assertTrue(mergedRegions.contains("O4:O135"));
assertTrue(mergedRegions.contains("A136:A140"));
assertTrue(mergedRegions.contains("G137:G140"));
assertTrue(mergedRegions.contains("H137:H140"));
assertTrue(mergedRegions.contains("A141:A148"));
assertTrue(mergedRegions.contains("G142:G148"));
assertTrue(mergedRegions.contains("H142:H148"));
assertEquals(10, workbook.getFontAt(workbook.getSheetAt(0).getRow(0).getCell(0)
.getCellStyle().getFontIndex()).getFontHeightInPoints());
assertEquals(12 * 256, workbook.getSheetAt(0).getColumnWidth(0));
assertEquals(14 * 256, workbook.getSheetAt(0).getColumnWidth(4));
assertEquals(14 * 256, workbook.getSheetAt(0).getColumnWidth(12));
assertEquals(32 * 256, workbook.getSheetAt(0).getColumnWidth(14));
assertEquals(9 * 256, workbook.getSheetAt(0).getColumnWidth(15));
assertEquals(32 * 256, workbook.getSheetAt(0).getColumnWidth(17));
assertEquals(14 * 256, workbook.getSheetAt(0).getColumnWidth(18));
assertEquals(10 * 256, workbook.getSheetAt(0).getColumnWidth(4));
assertEquals(10 * 256, workbook.getSheetAt(0).getColumnWidth(12));
assertEquals(11 * 256, workbook.getSheetAt(0).getColumnWidth(14));
assertEquals(32 * 256, workbook.getSheetAt(0).getColumnWidth(15));
assertEquals(11 * 256, workbook.getSheetAt(0).getColumnWidth(17));
assertEquals(32 * 256, workbook.getSheetAt(0).getColumnWidth(18));
assertEquals(12 * 256, workbook.getSheetAt(0).getColumnWidth(20));
XSSFCellStyle firstGroupTopStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(2)
.getCell(0).getCellStyle();
XSSFCellStyle firstGroupSideStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(3)
.getCell(0).getCellStyle();
XSSFCellStyle firstGroupRightStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(3)
.getCell(19).getCellStyle();
.getCell(20).getCellStyle();
XSSFCellStyle sameDateMealTopStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(135)
.getCell(0).getCellStyle();
XSSFCellStyle firstDateBottomStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(139)
@@ -109,7 +110,7 @@ class HardCanteenProductApptExportMergeHandlerTest {
/**
* 构造日期餐段汇总行。
*
* @param date 预约日期
* @param date 统计日期
* @param hourType 餐段名称
* @return 仅填写汇总列的导出行
*/
@@ -117,6 +118,7 @@ class HardCanteenProductApptExportMergeHandlerTest {
HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo();
row.setSummaryDate(date);
row.setSummaryHourType(hourType);
row.setSummarySaleSourceName("合计");
row.setSummaryPayUserCount(1L);
row.setSummaryTimeCount(1L);
row.setSummaryPayCount(1L);
@@ -127,7 +129,7 @@ class HardCanteenProductApptExportMergeHandlerTest {
/**
* 构造商户部门统计行。
*
* @param date 预约日期
* @param date 统计日期
* @param hourType 餐段名称
* @return 仅填写商户部门列的导出行
*/
@@ -58,7 +58,7 @@ class HardApptReservationServiceImplTest {
appt, APPT_DATE, LocalDateTime.of(2026, 7, 21, 10, 0, 0, 1)).available());
}
/** 动态预约直接快照已校验餐段的实际结束时间。 */
/** 动态预约固化用餐日当天 23:59:59 作为核销有效期结束时间。 */
@Test
void dynamicReservationUsesValidatedSegmentEndTime() {
HardApptEntity dynamic = timeWindowAppt();
@@ -71,16 +71,14 @@ class HardApptReservationServiceImplTest {
.validateReservation(10L, 1L, null, "1", APPT_DATE, null,
LocalDateTime.of(2026, 7, 20, 18, 0));
assertEquals(LocalDateTime.of(2026, 7, 21, 10, 0), validation.usageEndDateTime());
assertEquals(LocalDateTime.of(2026, 7, 21, 23, 59, 59), validation.usageEndDateTime());
}
/** 周期预约按有效 hard_appt 餐段解析结束时间,并支持结束时间落到用餐日次日。 */
/** 周期预约固化用餐日当天 23:59:59,不受当前 hard_appt 餐段配置影响。 */
@Test
void weeklyReservationResolvesCrossDaySegmentEndTime() {
void weeklyReservationUsesUseDateEndOfDay() {
HardApptEntity weekly = weeklyAppt(1L, 10L);
weekly.setHourType("1");
weekly.setEndTimeType(HardApptTimeTypeEnum.TOMORROW.getType());
weekly.setEndTime(LocalTime.of(2, 0));
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 7, "1");
@@ -88,20 +86,16 @@ class HardApptReservationServiceImplTest {
.validateReservation(10L, null, 100L, "1", LocalDate.of(2026, 7, 28), null,
LocalDateTime.of(2026, 7, 24, 18, 0));
assertEquals(LocalDateTime.of(2026, 7, 29, 2, 0), validation.usageEndDateTime());
assertEquals(LocalDateTime.of(2026, 7, 28, 23, 59, 59), validation.usageEndDateTime());
}
/** 周期餐段无法唯一匹配时不生成结束时间,避免按错误规则自动退款。 */
/** 周期预约即使存在重复餐段配置,也按订单用餐日固化全天核销有效期。 */
@Test
void weeklyReservationOmitsUsageEndTimeWhenSegmentIsAmbiguous() {
void weeklyReservationIgnoresDuplicateSegmentDefinitions() {
HardApptEntity weekly = weeklyAppt(1L, 10L);
weekly.setHourType("1");
weekly.setEndTimeType(HardApptTimeTypeEnum.TODAY.getType());
weekly.setEndTime(LocalTime.of(13, 0));
HardApptEntity duplicate = weeklyAppt(2L, 10L);
duplicate.setHourType("1");
duplicate.setEndTimeType(HardApptTimeTypeEnum.TODAY.getType());
duplicate.setEndTime(LocalTime.of(14, 0));
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 7, "1");
@@ -109,7 +103,7 @@ class HardApptReservationServiceImplTest {
.validateReservation(10L, null, 100L, "1", LocalDate.of(2026, 7, 28), null,
LocalDateTime.of(2026, 7, 24, 18, 0));
assertNull(validation.usageEndDateTime());
assertEquals(LocalDateTime.of(2026, 7, 28, 23, 59, 59), validation.usageEndDateTime());
}
@Test
@@ -4,11 +4,15 @@ import com.baomidou.lock.LockTemplate;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleMapper;
import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundAttemptLogMapper;
import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundTaskMapper;
@@ -28,6 +32,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDate;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -78,7 +83,6 @@ class HardAutoRefundTaskServiceImplTest {
/** 订单 Mapper Mock,用于候选订单和到期退款流程的订单锁查询。 */
@Mock
private HardOrderMapper orderMapper;
/** 被测自动退款任务服务。 */
private HardAutoRefundTaskServiceImpl service;
@@ -201,6 +205,67 @@ class HardAutoRefundTaskServiceImplTest {
assertThrows(RuntimeException.class, () -> spyService.manualExpiredRefund(1L, 99L));
}
/** 新订单专用核销结束时间必须优先于历史兜底,保证重扫始终使用下单时事实。 */
@Test
void expiredRescanShouldPreferDedicatedOrderSnapshot() {
HardOrderEntity order = HardOrderEntity.builder()
.expiredRefundUsageEndTime(LocalDateTime.of(2026, 8, 23, 13, 0))
.autoRefundDeadline(LocalDateTime.of(2026, 8, 23, 22, 0))
.apptUseDate(LocalDate.of(2026, 8, 23))
.build();
LocalDateTime result = ReflectionTestUtils.invokeMethod(service,
"resolveExpiredRefundUsageEndTimeSnapshot", order);
assertEquals(LocalDateTime.of(2026, 8, 23, 13, 0), result);
}
/** 历史订单没有专用字段时,只使用订单预约日期兜底到当天 23:59:59。 */
@Test
void expiredRescanShouldUseApptDateEndOfDayWhenDedicatedSnapshotMissing() {
HardOrderEntity order = HardOrderEntity.builder()
.autoRefundDeadline(LocalDateTime.of(2026, 8, 23, 22, 0))
.apptUseDate(LocalDate.of(2026, 8, 23))
.build();
LocalDateTime result = ReflectionTestUtils.invokeMethod(service,
"resolveExpiredRefundUsageEndTimeSnapshot", order);
assertEquals(LocalDateTime.of(2026, 8, 23, 23, 59, 59), result);
}
/** 开关关闭重扫只创建暂停任务,不会把历史订单推进到资金执行状态。 */
@Test
void disabledExpiredRescanShouldCreateSwitchDisabledTask() {
HardOrderEntity order = HardOrderEntity.builder()
.id(2091499615852482561L)
.merchantId(2079103103881199617L)
.orderType(HardOrderTypeEnum.RESERVE.getType())
.orderStatus(HardOrderStatusEnum.PAY.getType())
.payType(HardPayTypeEnum.WALLET_DEFAULT.getType())
.payPrice(4210)
.apptUseDate(LocalDate.of(2026, 8, 23))
.build();
MchAutoRefundConfigVo config = new MchAutoRefundConfigVo();
config.setId(2088157133475426306L);
config.setMchId(2079103103881199617L);
config.setOrderType(HardOrderTypeEnum.RESERVE.getType());
LocalDateTime triggerTime = LocalDateTime.of(2026, 8, 24, 0, 59, 59);
when(orderMapper.selectByIdForUpdate(order.getId())).thenReturn(order);
when(afterSaleMapper.selectCount(any())).thenReturn(0L);
when(taskMapper.selectOne(any())).thenReturn(null);
when(taskMapper.insert(any(HardAutoRefundTaskEntity.class))).thenReturn(1);
ArgumentCaptor<HardAutoRefundTaskEntity> taskCaptor = ArgumentCaptor.forClass(HardAutoRefundTaskEntity.class);
int result = service.syncExpiredRescanTask(order, config, triggerTime, YesNoEnum.NO.getCode(), false);
assertEquals(1, result);
verify(taskMapper).insert(taskCaptor.capture());
assertEquals(HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus(), taskCaptor.getValue().getStatus());
assertEquals(triggerTime, taskCaptor.getValue().getTriggerTime());
assertNull(taskCaptor.getValue().getNextRetryTime());
}
/**
* 构造固定订单和售后单的自动退款任务。
*
@@ -1,169 +1,28 @@
package com.bsd.pjcloud.hard.service.impl.order;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.entity.SysDictItem;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteDictService;
import com.bsd.pjcloud.canteen.api.feign.RemoteDailyMenuService;
import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
import com.bsd.pjcloud.hard.mapper.order.HardCarMapper;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.hard.service.HardCanteenApptTotalExportService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDate;
import java.util.List;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 菜品预约导出服务测试,验证日期餐段、部门合计及商品统计与套餐展开后的商品明细保持同一口径。
* 食堂预约导出服务边界测试,防止综合菜品销售导出再次回流到仅服务用户预约查询的旧服务。
*/
class HardCanteenApptTotalExportServiceImplTest {
/**
* 套餐商品展开后,日期餐段汇总应跨部门累计组成商品数量和金额,并在部门内合并同名单品。
* 验证预约导出服务只保留用户预约同步和异步导出,菜品销售统一由食堂销售统计服务负责。
*/
@Test
void shouldAggregateMealSegmentTotalsAcrossDepartmentsFromExpandedProductRows() {
HardCarMapper hardCarMapper = mock(HardCarMapper.class);
RemoteDailyMenuService remoteDailyMenuService = mock(RemoteDailyMenuService.class);
RemoteDeptService remoteDeptService = mock(RemoteDeptService.class);
RemoteDictService remoteDictService = mock(RemoteDictService.class);
RemoteMchService remoteMchService = mock(RemoteMchService.class);
HardCanteenApptTotalExportServiceImpl service = mock(HardCanteenApptTotalExportServiceImpl.class,
org.mockito.Answers.CALLS_REAL_METHODS);
ReflectionTestUtils.setField(service, "hardCarMapper", hardCarMapper);
ReflectionTestUtils.setField(service, "remoteDailyMenuService", remoteDailyMenuService);
ReflectionTestUtils.setField(service, "remoteDeptService", remoteDeptService);
ReflectionTestUtils.setField(service, "remoteDictService", remoteDictService);
ReflectionTestUtils.setField(service, "remoteMchService", remoteMchService);
void shouldExposeOnlyUserReservationExportOperations() {
Set<String> methodNames = Arrays.stream(HardCanteenApptTotalExportService.class.getDeclaredMethods())
.map(method -> method.getName())
.collect(Collectors.toSet());
LocalDate date = LocalDate.of(2026, 8, 24);
HardCanteenProductTotalVo summary = new HardCanteenProductTotalVo();
summary.setCreateTime(date.atStartOfDay());
summary.setHourType("1");
summary.setPayUserCount(2L);
summary.setTimeCount(2L);
summary.setPayCount(5L);
summary.setTotalPrice(800L);
HardCanteenProductApptExportRecord merchant = new HardCanteenProductApptExportRecord();
merchant.setApptDate(date.toString());
merchant.setHourType("1");
merchant.setMerchantId(10L);
merchant.setUserDeptId(20L);
merchant.setPayUserCount(1L);
merchant.setTimeCount(1L);
merchant.setPayCount(3L);
merchant.setTotalPrice(500L);
HardCanteenProductApptExportRecord product = new HardCanteenProductApptExportRecord();
product.setApptDate(date.toString());
product.setHourType("1");
product.setMerchantId(10L);
product.setUserDeptId(20L);
product.setProductId(100L);
product.setProductName("套餐");
product.setPayCount(1L);
product.setTotalPrice(300L);
HardCanteenProductApptExportRecord sameNameProduct = new HardCanteenProductApptExportRecord();
sameNameProduct.setApptDate(date.toString());
sameNameProduct.setHourType("1");
sameNameProduct.setMerchantId(10L);
sameNameProduct.setUserDeptId(20L);
sameNameProduct.setProductId(101L);
sameNameProduct.setProductName("主食");
sameNameProduct.setPayCount(2L);
sameNameProduct.setTotalPrice(200L);
HardCanteenProductApptExportRecord secondMerchant = new HardCanteenProductApptExportRecord();
secondMerchant.setApptDate(date.toString());
secondMerchant.setHourType("1");
secondMerchant.setMerchantId(10L);
secondMerchant.setUserDeptId(30L);
secondMerchant.setPayUserCount(1L);
secondMerchant.setTimeCount(1L);
secondMerchant.setPayCount(4L);
secondMerchant.setTotalPrice(500L);
HardCanteenProductApptExportRecord secondProduct = new HardCanteenProductApptExportRecord();
secondProduct.setApptDate(date.toString());
secondProduct.setHourType("1");
secondProduct.setMerchantId(10L);
secondProduct.setUserDeptId(30L);
secondProduct.setProductId(200L);
secondProduct.setProductName("单品");
secondProduct.setPayCount(4L);
secondProduct.setTotalPrice(500L);
DailyMenuAppVO menu = new DailyMenuAppVO();
menu.setSpuId(100L);
OnlineSpuComboItemVO first = new OnlineSpuComboItemVO();
first.setName("主食");
first.setQuantity(1);
first.setPrice(100);
OnlineSpuComboItemVO second = new OnlineSpuComboItemVO();
second.setName("配菜");
second.setQuantity(2);
second.setPrice(100);
menu.setComboItems(List.of(first, second));
MchManageVo merchantVo = new MchManageVo();
merchantVo.setId(10L);
merchantVo.setName("测试食堂");
SysDept dept = new SysDept();
dept.setDeptId(20L);
dept.setName("测试部门");
SysDept secondDept = new SysDept();
secondDept.setDeptId(30L);
secondDept.setName("第二部门");
SysDictItem dictItem = new SysDictItem();
dictItem.setItemValue("1");
dictItem.setLabel("早餐");
when(hardCarMapper.listApptExportSummary(any())).thenReturn(List.of(summary));
when(hardCarMapper.listApptExportMerchant(any())).thenReturn(List.of(merchant, secondMerchant));
when(hardCarMapper.listApptExportProduct(any())).thenReturn(List.of(product, sameNameProduct, secondProduct));
when(remoteDailyMenuService.getCanteenList(date.toString(), 1, null, 10L)).thenReturn(R.ok(List.of(menu)));
when(remoteMchService.getByIds(List.of(10L))).thenReturn(R.ok(List.of(merchantVo)));
when(remoteDeptService.getDeptByDeptIds(List.of(20L, 30L)))
.thenReturn(R.ok(java.util.Map.of(20L, dept, 30L, secondDept)));
when(remoteDictService.getDictByType("daily_menu")).thenReturn(R.ok(List.of(dictItem)));
List<HardCanteenProductApptExcelVo> rows = ReflectionTestUtils.invokeMethod(service, "buildProductRows",
null, date);
assertEquals(10, rows.size());
assertEquals(9L, rows.get(0).getSummaryPayCount());
assertEquals("10.00", rows.get(0).getSummaryTotalPrice().toPlainString());
assertEquals(5L, rows.get(1).getMerchantPayCount());
assertEquals("5.00", rows.get(1).getMerchantTotalPrice().toPlainString());
assertEquals(1L, rows.get(2).getProductCount());
assertEquals(2L, rows.get(3).getProductCount());
assertEquals(2L, rows.get(4).getProductCount());
assertEquals("主食", rows.get(5).getStatisticProductName());
assertEquals(3L, rows.get(5).getStatisticProductCount());
assertEquals("3.00", rows.get(5).getStatisticProductTotalPrice().toPlainString());
assertEquals("配菜", rows.get(6).getStatisticProductName());
assertEquals(2L, rows.get(6).getStatisticProductCount());
assertEquals("2.00", rows.get(6).getStatisticProductTotalPrice().toPlainString());
assertEquals(4L, rows.get(7).getMerchantPayCount());
assertEquals("5.00", rows.get(7).getMerchantTotalPrice().toPlainString());
assertEquals(4L, rows.get(8).getProductCount());
assertEquals("单品", rows.get(9).getStatisticProductName());
assertEquals(4L, rows.get(9).getStatisticProductCount());
assertEquals("5.00", rows.get(9).getStatisticProductTotalPrice().toPlainString());
assertEquals("早餐", rows.get(0).getSummaryHourType());
assertEquals(Set.of("submitUserReservationExport", "userReservationExport"), methodNames);
}
}
@@ -4,29 +4,50 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.feign.RemoteDataScopeService;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteDictService;
import com.bsd.pjcloud.admin.api.feign.RemoteFileService;
import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenCategoryService;
import com.bsd.pjcloud.canteen.api.feign.RemoteDailyMenuService;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.entity.SysDictItem;
import com.bsd.pjcloud.common.core.util.R;
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.service.AsyncTaskResultAction;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.hard.api.constant.order.HardCanteenStatisticsLevelEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardSaleSourceEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
import com.bsd.pjcloud.hard.mapper.order.HardCarMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -44,6 +65,18 @@ class HardCanteenTotalServiceImplTest {
/** 食堂范围远程服务,用于部门统计返回可查询的食堂集合。 */
private final RemoteMchService remoteMchService = mock(RemoteMchService.class);
/** 每日菜谱远程服务,用于隔离导出中的套餐展开查询。 */
private final RemoteDailyMenuService remoteDailyMenuService = mock(RemoteDailyMenuService.class);
/** 餐段字典远程服务,用于校验导出的餐段中文名称。 */
private final RemoteDictService remoteDictService = mock(RemoteDictService.class);
/** 文件中心远程服务,用于验证异步导出上传和结果地址。 */
private final RemoteFileService remoteFileService = mock(RemoteFileService.class);
/** 通用异步任务执行器,用于捕获后台导出动作并在测试线程执行。 */
private final AsyncTaskExecutorService asyncTaskExecutorService = mock(AsyncTaskExecutorService.class);
/** 被测统计服务。 */
private HardCanteenTotalServiceImpl service;
@@ -59,7 +92,10 @@ class HardCanteenTotalServiceImplTest {
remoteMchService,
mock(RemoteDataScopeService.class),
mock(RemoteCanteenCategoryService.class),
mock(RemoteDictService.class));
remoteDailyMenuService,
remoteDictService,
remoteFileService,
asyncTaskExecutorService);
}
/**
@@ -153,6 +189,7 @@ class HardCanteenTotalServiceImplTest {
assertEquals(1, result.get(0).getSourceRows().get(0).getChildren().size());
assertEquals(HardSaleSourceEnum.OFFLINE_PURCHASE.getCode(),
result.get(0).getSourceRows().get(0).getChildren().get(0).getSaleSource());
verify(hardCarMapper, times(1)).listByHourTypeStats(any(HardOrderDto.class));
}
/**
@@ -180,6 +217,137 @@ class HardCanteenTotalServiceImplTest {
assertEquals(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()), dto.getSaleSourceCodes());
}
/**
* 验证综合导出为同一日期餐段依次生成合计、线下购买和预约核销三个完整三级块。
*/
@Test
void exportSalesBuildsTotalAndSourceBlocksWithMerchantAndProductRows() {
HardOrderDto dto = statisticsDto();
mockCanteenScope();
SysDept department = new SysDept();
department.setDeptId(20L);
department.setName("测试部门");
SysDictItem hourType = new SysDictItem();
hourType.setItemValue("1");
hourType.setLabel("早餐");
when(remoteDeptService.getDeptByDeptIds(List.of(20L))).thenReturn(R.ok(java.util.Map.of(20L, department)));
when(remoteDictService.getDictByType("daily_menu")).thenReturn(R.ok(List.of(hourType)));
when(remoteDailyMenuService.getCanteenList("2026-08-24", 1, null, 10L)).thenReturn(R.ok(List.of()));
List<HardCanteenProductApptExportRecord> summaries = List.of(
exportRecord(null, null),
exportRecord(HardSaleSourceEnum.OFFLINE_PURCHASE, null),
exportRecord(HardSaleSourceEnum.RESERVATION_VERIFIED, null));
List<HardCanteenProductApptExportRecord> merchants = List.of(
exportRecord(null, 20L),
exportRecord(HardSaleSourceEnum.OFFLINE_PURCHASE, 20L),
exportRecord(HardSaleSourceEnum.RESERVATION_VERIFIED, 20L));
List<HardCanteenProductApptExportRecord> products = List.of(
exportProductRecord(null, "全部商品"),
exportProductRecord(HardSaleSourceEnum.OFFLINE_PURCHASE, "线下商品"),
exportProductRecord(HardSaleSourceEnum.RESERVATION_VERIFIED, "预约商品"));
List<HardCanteenProductApptExportRecord> exportRecords = new ArrayList<>();
exportRecords.addAll(summaries);
exportRecords.addAll(merchants);
exportRecords.addAll(products);
when(hardCarMapper.listSalesExportRows(any(HardOrderDto.class), isNull())).thenReturn(exportRecords);
List<HardCanteenProductApptExcelVo> rows = service.exportSales(dto, null);
assertEquals(12, rows.size());
assertEquals(List.of("合计", "线下购买", "预约核销"), rows.stream()
.filter(row -> "SUMMARY".equals(row.getRowLevel()))
.map(HardCanteenProductApptExcelVo::getSummarySaleSourceName).toList());
assertEquals(3, rows.stream().filter(row -> "MERCHANT".equals(row.getRowLevel())).count());
assertEquals(3, rows.stream().filter(row -> "PRODUCT".equals(row.getRowLevel())).count());
assertEquals(3, rows.stream().filter(row -> "PRODUCT_STATISTIC".equals(row.getRowLevel())).count());
assertEquals(List.of(HardSaleSourceEnum.OFFLINE_PURCHASE.getCode(),
HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()), dto.getSaleSourceCodes());
}
/**
* 验证跨日导出只按自然日拆批,不会把首尾日扩展到用户未选择的时间段。
*/
@Test
void exportSalesPreservesSelectedTimesAtDateRangeBoundaries() {
HardOrderDto dto = new HardOrderDto();
dto.setStatisticsDateTimeList(new LocalDateTime[]{
LocalDateTime.of(2026, 8, 23, 8, 30),
LocalDateTime.of(2026, 8, 24, 12, 15)
});
mockCanteenScope();
service.exportSales(dto, null);
ArgumentCaptor<HardOrderDto> queryCaptor = ArgumentCaptor.forClass(HardOrderDto.class);
verify(hardCarMapper, atLeastOnce()).listSalesExportRows(queryCaptor.capture(), isNull());
List<HardOrderDto> queries = queryCaptor.getAllValues();
assertEquals(2, queries.size());
assertEquals(LocalDateTime.of(2026, 8, 23, 8, 30), queries.get(0).getStatisticsDateTimeList()[0]);
assertEquals(LocalDateTime.of(2026, 8, 23, 23, 59, 59), queries.get(0).getStatisticsDateTimeList()[1]);
assertEquals(LocalDateTime.of(2026, 8, 24, 0, 0), queries.get(1).getStatisticsDateTimeList()[0]);
assertEquals(LocalDateTime.of(2026, 8, 24, 12, 15), queries.get(1).getStatisticsDateTimeList()[1]);
}
/**
* 验证异步菜品导出冻结查询快照、按日期推进进度并返回文件中心下载结果。
*
* @throws Exception 执行捕获的异步任务动作失败时抛出
*/
@Test
void productSalesAsyncExportStreamsDateBatchAndReturnsUploadedFile() throws Exception {
HardOrderDto dto = statisticsDto();
dto.setUserDeptIds(new ArrayList<>(List.of(20L)));
mockCanteenScope();
when(remoteDictService.getDictByType("daily_menu")).thenReturn(R.ok(List.of()));
when(hardCarMapper.listSalesExportRows(any(HardOrderDto.class), isNull()))
.thenReturn(List.of(exportRecord(null, null)));
when(remoteFileService.innerUpload(any(), eq("hard-export"), isNull(),
eq("HARD_CANTEEN_PRODUCT_SALES_EXPORT")))
.thenReturn(R.ok(Map.of("url", "https://file.example/sales.xlsx")));
AsyncTaskProgressVO initialProgress = new AsyncTaskProgressVO();
initialProgress.setTaskId("test-task");
when(asyncTaskExecutorService.submitWithResult(anyString(), anyString(), anyInt(),
any(AsyncTaskResultAction.class))).thenReturn(initialProgress);
service.submitProductSalesExport(dto);
dto.getStatisticsDateTimeList()[0] = LocalDateTime.of(2026, 8, 25, 0, 0);
dto.getUserDeptIds().add(30L);
ArgumentCaptor<AsyncTaskResultAction> actionCaptor = ArgumentCaptor.forClass(AsyncTaskResultAction.class);
verify(asyncTaskExecutorService).submitWithResult(eq("hard:canteen-sales:export:product"),
eq("食堂菜品销售统计导出"), eq(1), actionCaptor.capture());
AsyncTaskProgressReporter reporter = mock(AsyncTaskProgressReporter.class);
AsyncTaskResult result = actionCaptor.getValue().execute(reporter);
ArgumentCaptor<HardOrderDto> queryCaptor = ArgumentCaptor.forClass(HardOrderDto.class);
verify(hardCarMapper).listSalesExportRows(queryCaptor.capture(), isNull());
assertEquals(LocalDateTime.of(2026, 8, 24, 0, 0),
queryCaptor.getValue().getStatisticsDateTimeList()[0]);
assertEquals(List.of(20L), queryCaptor.getValue().getUserDeptIds());
assertEquals("https://file.example/sales.xlsx", result.fileUrl());
assertTrue(result.fileName().startsWith("食堂菜品销售统计_"));
verify(reporter).updateTotal(eq(3), contains("1 个日期批次"));
verify(reporter, times(3)).advance(anyString());
}
/**
* 验证部门统计使用独立任务命名空间,避免与菜品统计进度互相覆盖。
*/
@Test
void departmentSalesAsyncExportUsesIndependentTaskNamespace() {
HardOrderDto dto = statisticsDto();
mockCanteenScope();
AsyncTaskProgressVO initialProgress = new AsyncTaskProgressVO();
when(asyncTaskExecutorService.submitWithResult(anyString(), anyString(), anyInt(),
any(AsyncTaskResultAction.class))).thenReturn(initialProgress);
service.submitDepartmentSalesExport(dto);
verify(asyncTaskExecutorService).submitWithResult(eq("hard:canteen-sales:export:department"),
eq("食堂菜品部门统计导出"), eq(1), any(AsyncTaskResultAction.class));
}
/**
* 构造覆盖 2026-08-24 完整自然日的来源统计请求。
*
@@ -243,4 +411,41 @@ class HardCanteenTotalServiceImplTest {
row.setUserDeptId(userDeptId);
return row;
}
/**
* 构造指定来源口径的日期餐段或商户部门导出聚合记录。
*
* @param source 销售来源;为空表示当前筛选范围合计
* @param userDeptId 用户部门主键;为空表示日期餐段汇总层级
* @return 可与商品聚合记录使用相同分组键的导出记录
*/
private HardCanteenProductApptExportRecord exportRecord(HardSaleSourceEnum source, Long userDeptId) {
HardCanteenProductApptExportRecord row = new HardCanteenProductApptExportRecord();
row.setRecordLevel(userDeptId == null ? "SUMMARY" : "MERCHANT");
row.setStatisticsDate(LocalDate.of(2026, 8, 24).toString());
row.setHourType("1");
row.setSaleSource(source == null ? null : source.getCode());
row.setMerchantId(userDeptId == null ? null : 10L);
row.setUserDeptId(userDeptId);
row.setPayUserCount(1L);
row.setTimeCount(1L);
row.setPayCount(1L);
row.setTotalPrice(100L);
return row;
}
/**
* 构造指定来源口径的商品导出聚合记录。
*
* @param source 销售来源;为空表示当前筛选范围合计
* @param productName 商品快照名称
* @return 带食堂、部门和商品键的导出记录
*/
private HardCanteenProductApptExportRecord exportProductRecord(HardSaleSourceEnum source, String productName) {
HardCanteenProductApptExportRecord row = exportRecord(source, 20L);
row.setRecordLevel("PRODUCT");
row.setProductId(source == null ? 100L : source == HardSaleSourceEnum.OFFLINE_PURCHASE ? 101L : 102L);
row.setProductName(productName);
return row;
}
}
@@ -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());
}
/**
* 构造旧消费机通用核销接口请求。
*
@@ -15,6 +15,8 @@ public enum ThirdPaymentTaskTypeEnum {
REFUND_QUERY("REFUND_QUERY"),
/** 普通退款创建结果补偿。 */
REFUND_CREATE("REFUND_CREATE"),
/** 商户微信或支付宝默认平台分佣关系绑定。 */
FZ_RELATION_BIND("FZ_RELATION_BIND"),
/** 微信或支付宝分账创建/阶段推进。 */
FZ_CREATE("FZ_CREATE"),
/** 支付宝直付通确认结算;只推进确认结算阶段。 */
@@ -11,6 +11,11 @@ public class ThirdAppPlusPayDto {
private Long mchId;
/**
* 支付创建前由服务端解析的已绑定平台分佣关系主键;客户端不得提交或覆盖。
*/
private Long fzRelationId;
/**
* 子商户id
*/
@@ -0,0 +1,59 @@
package com.bsd.pjcloud.thirdparty.dto.pay;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 商户分账关系管理参数。
*
* <p>租户和支付平台由 thirdparty 根据 {@code paySettingId} 重新读取;二级商户、
* 支付宝平台账号业务标识及授权上下文属于关系事实,由管理端在该子资源中维护;
* SDK 私钥和支付宝公钥仍由 thirdparty 平台统一配置持有。</p>
*/
@Data
public class ThirdMchFzRelationSaveDto {
/** 已有关系主键;新增时为空。 */
private Long id;
/** 关系所属商户主键,必须与支付配置所属商户一致。 */
@NotNull(message = "商户不能为空")
private Long mchId;
/** 关系使用的支付配置主键,决定渠道、二级商户和平台账号。 */
@NotNull(message = "支付配置不能为空")
private Long paySettingId;
/** 微信 sub_mchid 或支付宝直付通 smid/ip_role_id;编辑后变化时服务端创建新关系版本。 */
@Size(max = 64, message = "二级商户标识不能超过64个字符")
private String channelSubMchId;
/** 支付宝平台账号业务标识,仅用于关系审计;空字符串表示使用平台默认标识。 */
@Size(max = 64, message = "平台账号键不能超过64个字符")
private String accountKey;
/** 支付宝第三方代调用授权令牌;为空表示新增时不使用、编辑时保留原值。 */
@Size(max = 512, message = "支付宝授权令牌不能超过512个字符")
private String appAuthToken;
/** 渠道接收方类型;新增时必填,编辑后变化时必须同时提交完整新标识并创建新关系版本。 */
private String receiverType;
/** 渠道接收方账号、登录号、openId 或卡别名号;编辑时为空表示保留原完整值,非空变化时创建新关系版本。 */
@Size(max = 128, message = "接收方标识不能超过128个字符")
private String receiverAccount;
/** 渠道展示的接收方名称;支付宝和微信均允许为空,变化后原关系会使用新请求号重新绑定。 */
@Size(max = 128, message = "接收方名称不能超过128个字符")
private String receiverName;
/** 平台抽佣比例,单位为百分比,最多保留三位小数;新增关系时必填,修改默认关系时可省略。 */
private Double fzRatio;
/** 微信分账关系类型;支付宝关系不使用,变化后原关系会使用新请求号重新绑定。 */
@Size(max = 64, message = "关系类型不能超过64个字符")
private String channelRelationType;
/** 是否设为当前支付配置默认关系,1为是、0为否。 */
private String isDefault;
}
@@ -40,14 +40,18 @@ public class ThirdMchFzRelationEntity extends Model<ThirdMchFzRelationEntity> {
private String channelAppId;
/** 微信 sub_mchid 或支付宝 smid/ip_role_id,用于绑定关系与商户支付配置对应。 */
private String channelSubMchId;
/** 支付宝命名平台账号键;默认支付宝账号及微信关系为空。 */
/** 支付宝平台账号业务标识,仅用于关系审计;SDK 密钥统一读取服务端支付宝平台配置,微信关系为空。 */
private String accountKey;
/** 渠道接收方类型,微信为 MERCHANT_ID,支付宝为 userId 或 loginName。 */
/** 支付宝第三方代调用授权令牌;仅服务端渠道调用使用,管理接口不得返回完整值。 */
private String appAuthToken;
/** 渠道接收方类型,微信使用 SDK ReceiverType,支付宝支持 userId、loginName、openId、cardAliasNo。 */
private String receiverType;
/** 平台分佣接收账号,发起渠道请求时使用;管理端展示必须脱敏。 */
private String receiverAccount;
/** 平台分佣接收方名称快照,渠道不要求时允许为空。 */
private String receiverName;
/** 当前关系的平台抽佣比例,单位为百分比,范围为0到100,持久化精度为0.001%;新支付和分账金额只读取该关系快照。 */
private Double fzRatio;
/** 渠道关系类型,微信默认 SERVICE_PROVIDER,支付宝关系允许为空。 */
private String channelRelationType;
/** 渠道绑定稳定幂等号;支付宝重试必须保持不变,微信用于本地审计。 */
@@ -72,6 +72,11 @@ public class ThirdPayOrderEntity extends Model<ThirdPayOrderEntity> {
@Schema(description = "商户支付配置")
private String mchPaySettingJson;
/**
* 支付创建时固定的平台分佣关系主键,后续分账和退分账必须使用该历史关系快照。
*/
private Long fzRelationId;
/**
* 用户code
*/
@@ -221,4 +226,4 @@ public class ThirdPayOrderEntity extends Model<ThirdPayOrderEntity> {
*/
@Schema(description = "tenantId")
private Long tenantId;
}
}
@@ -31,6 +31,8 @@ public class ThirdPaymentTaskEntity extends Model<ThirdPaymentTaskEntity> {
private Long payOrderId;
/** 关联分账订单主键;非分账及退分账任务允许为空。 */
private Long fzOrderId;
/** 关联商户分账关系主键;仅关系绑定任务必填,禁止从任务键字符串反解析。 */
private Long fzRelationId;
/** 关联聚合支付会话主键;非聚合支付任务允许为空。 */
private Long aggregateSessionId;
/** 平台内稳定支付单号,来源于支付订单;未关联支付订单的任务允许为空。 */
@@ -0,0 +1,51 @@
package com.bsd.pjcloud.thirdparty.vo.pay;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 管理端展示的商户分账关系。
*
* <p>接收方账号已脱敏,绑定请求和响应快照不返回浏览器,避免把渠道报文
* 或内部配置暴露给管理端页面。</p>
*/
@Data
public class ThirdMchFzRelationVo {
/** 关系主键。 */
private Long id;
/** 关系所属商户主键。 */
private Long mchId;
/** 关系使用的支付配置主键。 */
private Long paySettingId;
/** 支付平台编码:10微信、20支付宝。 */
private String platformType;
/** 微信子商户号或支付宝直付通 smid/ip_role_id。 */
private String channelSubMchId;
/** 支付宝平台账号业务标识,仅用于关系审计;微信为空。 */
private String accountKey;
/** 是否已配置支付宝授权令牌;管理端只接收布尔状态,绝不返回完整令牌。 */
private Boolean appAuthTokenConfigured;
/** 接收方类型。 */
private String receiverType;
/** 脱敏后的接收方标识。 */
private String receiverAccount;
/** 接收方展示名称。 */
private String receiverName;
/** 当前关系的平台抽佣比例,单位为百分比,最多保留三位小数(0.001%)。 */
private Double fzRatio;
/** 微信关系类型;支付宝为空。 */
private String channelRelationType;
/** 当前关系绑定状态。 */
private String status;
/** 是否为当前支付配置默认关系。 */
private String isDefault;
/** 渠道确认绑定时间。 */
private LocalDateTime boundTime;
/** 最近一次渠道状态。 */
private String lastExternalStatus;
/** 最近一次错误摘要。 */
private String bindError;
/** 最近一次绑定或查询时间。 */
private LocalDateTime lastQueryTime;
}
@@ -4,9 +4,6 @@ import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 小程序平台配置
*/
@@ -18,7 +15,7 @@ public class AppPlusPayConfig {
private WechatConfig wechatConfig;
private AliPayConfig aliPayConfig;
private AliPayConfig AliPayConfig;
@Data
@@ -49,6 +46,16 @@ public class AppPlusPayConfig {
*/
private String merchantId;
/**
* 微信平台分佣接收商户号;为空时使用服务商 {@link #merchantId},不能由商户支付配置覆盖。
*/
private String profitSharingReceiverAccount;
/**
* 微信平台分佣接收方名称;为空时使用平台默认企业名称,仅用于渠道关系和分账请求。
*/
private String profitSharingReceiverName;
/**
* 商户私钥
*/
@@ -146,26 +153,6 @@ public class AppPlusPayConfig {
*/
private String subscribeUrl;
/**
* 可选的平台支付宝命名账号,键由商户支付配置中的 accountKey 引用;空配置仍使用当前账号本身。
*/
private Map<String, AliPayConfig> accounts = new LinkedHashMap<>();
/**
* 直付通平台抽佣接收支付宝账号,类型使用 userId 或 loginName。
*/
private String royaltyTransIn;
/**
* 直付通平台抽佣接收账号类型,支付宝支持 userId 或 loginName。
*/
private String royaltyTransInType;
/**
* 直付通平台抽佣接收方名称,用于支付宝分账请求描述。
*/
private String royaltyTransInName;
}
}
@@ -1,25 +1,20 @@
package com.bsd.pjcloud.thirdparty.controller.appplus;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.json.JSONUtil;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMicropayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMiniRemotePayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdOrderCodePayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdRemoterRefundDto;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusService;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatFzService;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdMiniAppPayVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayStatusVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -31,8 +26,6 @@ import org.springframework.http.HttpHeaders;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 第三方支付内部远程调用入口,统一通过 {@link Inner} 约束服务间访问。
*/
@@ -46,9 +39,9 @@ import java.util.List;
public class ThirdAppPlusController {
/**
* 微信分账服务,用于现有商户配置变更后的接收方同步。
* 商户分账关系服务,用于统一物化微信和支付宝默认平台分佣关系。
*/
private final ThirdAppPlusWechatFzService thirdAppPlusWechatFzService;
private final ThirdMchFzRelationService mchFzRelationService;
/**
* 第三方支付编排服务,承接退款、付款码、订单码和 App 支付请求。
@@ -151,7 +144,7 @@ public class ThirdAppPlusController {
/* ************************************* end 远程调用 end ************************************* */
/**
* 同步商户配置变更到微信分账接收方维护流程。
* 同步商户启用支付配置到微信和支付宝共用的分账关系事实。
*
* @param dto 变更后的商户详情和支付配置
* @return 接收变更请求后返回 {@code true}
@@ -160,19 +153,7 @@ public class ThirdAppPlusController {
@PostMapping("/change-mch")
@Inner
public R<Boolean> remoterChangeMch(@RequestBody MchManageVo dto) {
if (ObjectUtil.isNotEmpty(dto)) {
List<MchPaySettingVo> paySettingList = dto.getPaySettingList();
if (ObjectUtil.isNotEmpty(paySettingList)) {
for (MchPaySettingVo v : paySettingList) {
if (ObjectUtil.equals(v.getPlatformType(), AppPlusPlatformPayType.WECHAT.getType())) {
MchPaySettingEntity.WechatPaySetting jsonSetting = JSONUtil.toBean(v.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class);
if (ObjectUtil.isNotEmpty(jsonSetting)) {
thirdAppPlusWechatFzService.addReceiver(jsonSetting.getSubMchid());
}
}
}
}
}
mchFzRelationService.syncDefaultRelations(dto);
return R.ok(true);
}
@@ -0,0 +1,103 @@
package com.bsd.pjcloud.thirdparty.controller.pay;
import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdMchFzRelationSaveDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdMchFzRelationVo;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 商户支付分账关系管理接口。
*
* <p>关系属于支付配置的一对多子资源,二级商户、支付宝平台账号和代调用授权也归关系所有;
* 渠道 AppID 由 thirdparty 的平台统一配置解析,关系中的账号键只作为业务标识和审计快照。</p>
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/third-mch-fz-relation")
@Tag(name = "商户支付分账关系", description = "商户微信/支付宝分账关系管理")
public class ThirdMchFzRelationController {
/** 分账关系业务服务。 */
private final ThirdMchFzRelationService relationService;
/** 商户服务,用于复核当前管理员可访问的商户范围。 */
private final RemoteMchService remoteMchService;
/** 查询支付配置下的全部关系。 */
@Operation(summary = "查询分账关系")
@GetMapping("/list")
@PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')")
public R<List<ThirdMchFzRelationVo>> list(@RequestParam Long mchId, @RequestParam Long paySettingId) {
requireMerchantAccess(mchId);
return R.ok(relationService.listForManage(mchId, paySettingId));
}
/** 新增关系或编辑关系展示名称、抽佣比例、授权 token 和默认标记。 */
@Operation(summary = "保存分账关系")
@SysLog("保存商户支付分账关系")
@PostMapping
@PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')")
public R<ThirdMchFzRelationVo> save(@Valid @RequestBody ThirdMchFzRelationSaveDto dto) {
requireMerchantAccess(dto.getMchId());
return R.ok(relationService.saveForManage(dto));
}
/** 删除关系;已绑定关系由服务层保留为历史事实。 */
@Operation(summary = "删除分账关系")
@SysLog("删除商户支付分账关系")
@DeleteMapping
@PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')")
public R<Boolean> remove(@RequestBody List<Long> ids) {
if (ids != null) {
for (Long id : ids) {
ThirdMchFzRelationEntity relation = relationService.getById(id);
if (relation == null) {
continue;
}
requireMerchantAccess(relation.getMchId());
}
}
return R.ok(relationService.removeForManage(ids));
}
/** 使用原关系主键和原支付宝请求号重新排队绑定。 */
@Operation(summary = "重试分账关系绑定")
@SysLog("重试商户支付分账关系绑定")
@PostMapping("/{id}/retry")
@PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')")
public R<Boolean> retry(@PathVariable Long id, @RequestParam Long mchId) {
requireMerchantAccess(mchId);
ThirdMchFzRelationEntity relation = relationService.getById(id);
if (relation == null || !mchId.equals(relation.getMchId())) {
throw new IllegalArgumentException("分账关系不属于指定商户");
}
return R.ok(relationService.retryForManage(id));
}
/** 复核管理员商户数据权限,避免仅凭前端 mchId 越权查看其他商户关系。 */
private void requireMerchantAccess(Long mchId) {
R<List<Long>> result = remoteMchService.getSelfMchIdList(SecurityUtils.getUser().getId(), MerchantTypeEnum.all());
if (result == null || !result.isOk() || result.getData() == null || !result.getData().contains(mchId)) {
throw new IllegalArgumentException("当前用户无权管理该商户分账关系");
}
}
}
@@ -165,4 +165,16 @@ public interface ThirdPaymentTaskMapper extends PjcloudBaseMapper<ThirdPaymentTa
* @return 状态允许重试且更新成功为1
*/
int manualRetry(@Param("id") Long id, @Param("nextRunTime") LocalDateTime nextRunTime);
/**
* 配置重新保存后唤醒同一关系的历史终态或退避任务,保留原任务键与关系主键。
*
* @param tenantId 关系所属租户ID
* @param taskKey 稳定关系任务键
* @param nextRunTime 重新执行时间
* @return 任务状态允许重新唤醒且更新成功时为1
*/
@InterceptorIgnore(tenantLine = "true")
int rearmByTaskKey(@Param("tenantId") Long tenantId, @Param("taskKey") String taskKey,
@Param("nextRunTime") LocalDateTime nextRunTime);
}
@@ -38,6 +38,9 @@ public class ThirdAggregatePayCreateContext {
*/
private Long mchId;
/** 创建子订单时固定的平台分佣关系主键;由服务端在平台选择后填充。 */
private Long fzRelationId;
/**
* 应付金额,单位为人民币分。
*/
@@ -1,29 +1,95 @@
package com.bsd.pjcloud.thirdparty.service;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdMchFzRelationSaveDto;
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdMchFzRelationVo;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import java.util.List;
/**
* 商户支付分账关系服务,负责把 {@code mch_manage} 的支付配置映射为渠道关系事实。
*/
public interface ThirdMchFzRelationService {
/**
* 根据商户当前启用支付配置物化默认平台分佣关系;已有相同身份关系只做幂等复用。
* 查询商户支付配置下的全部关系,账号必须以脱敏视图返回。
*
* @param mchId 商户主键
* @param paySettingId 支付配置主键
* @return 当前租户未删除关系,默认关系排在前面
*/
List<ThirdMchFzRelationVo> listForManage(Long mchId, Long paySettingId);
/**
* 新增或更新关系管理记录;渠道身份变化时创建新版本,避免覆盖历史支付订单引用的关系事实。
*
* @param dto 管理端关系参数
* @return 保存后的脱敏关系;身份变化时返回新关系主键
*/
ThirdMchFzRelationVo saveForManage(ThirdMchFzRelationSaveDto dto);
/**
* 删除关系;已绑定关系只标记为历史关系,默认关系删除前必须自动切换到可用替代关系。
*
* @param ids 关系主键列表
* @return 全部处理成功时返回 true
*/
boolean removeForManage(List<Long> ids);
/**
* 将关系放回待绑定状态并创建稳定任务,人工重试不得生成新的关系或请求号。
*
* @param id 关系主键
* @return 任务已重新排队时返回 true
*/
boolean retryForManage(Long id);
/**
* 同步商户支付配置生命周期;渠道字段迁入关系表后,只停用已删除或禁用配置的默认关系。
*
* @param merchant 商户及其启用支付配置,来源于 wallet 服务的内部快照
*/
void syncDefaultRelations(MchManageVo merchant);
/**
* 获取支付创建时必须使用的已绑定关系;有抽佣配置但关系未绑定时直接拒绝支付。
* 获取支付创建时必须使用的默认且已绑定关系;关系缺失或未绑定时直接拒绝支付。
*
* @param setting 商户支付配置
* @return 已绑定关系;未启用平台抽佣时返回 {@code null}
* @return 当前默认且已绑定的关系事实
*/
ThirdMchFzRelationEntity requireBound(MchPaySettingVo setting);
/**
* 校验内部支付请求携带的关系仍属于当前商户支付配置,防止绕过编排层伪造关系主键。
*
* @param relationId 支付编排层解析出的关系主键
* @param setting 当前商户支付配置
* @return 默认、已绑定且抽佣比例有效的关系快照
*/
ThirdMchFzRelationEntity requirePaymentRelation(Long relationId, MchPaySettingVo setting);
/**
* 使用固定关系生成订单级渠道配置快照,供渠道调用及后续查单、退款恢复使用。
*
* <p>该快照只写入 thirdparty 支付订单,不回写 {@code mch_pay_setting.setting_json};
* 其中二级商户号、支付宝账号业务标识和授权令牌全部来源于关系记录;支付宝 SDK 密钥仍读取平台统一配置。</p>
*
* @param setting 当前启用的支付配置,只提供商户、平台和配置主键
* @param relation 已校验的默认关系事实
* @return 包含关系渠道参数的独立支付配置快照
*/
MchPaySettingVo createPaymentSettingSnapshot(MchPaySettingVo setting, ThirdMchFzRelationEntity relation);
/**
* 按支付订单保存的关系主键读取历史关系快照,供分账和退分账使用。
*
* @param relationId 关系主键
* @return 关系快照;关系不存在时返回 {@code null}
*/
ThirdMchFzRelationEntity getById(Long relationId);
/**
* 执行一条关系绑定任务;重复执行必须使用关系事实中保存的稳定请求号和参数。
*
@@ -19,6 +19,52 @@ public class ThirdPaymentTaskEnqueuer {
/** 支付任务 Mapper。 */
private final ThirdPaymentTaskMapper taskMapper;
/**
* 为商户默认平台分佣关系写入稳定绑定任务;配置重新保存时可唤醒原终态任务。
*
* @param tenantId 关系所属租户ID
* @param relationId 分账关系主键
* @param nextRunTime 最早执行时间
* @param priority 调度优先级
*/
public void enqueueRelationBind(Long tenantId, Long relationId, LocalDateTime nextRunTime, int priority) {
ThirdPaymentTaskEntity task = ensureRelationBind(tenantId, relationId, nextRunTime, priority);
taskMapper.rearmByTaskKey(task.getTenantId(), task.getTaskKey(), task.getNextRunTime());
}
/**
* 确保关系绑定任务存在但不打断既有退避;支付请求只能催生缺失任务,不能用用户流量放大渠道重试。
*
* @param tenantId 关系所属租户ID
* @param relationId 分账关系主键
* @param nextRunTime 最早执行时间
* @param priority 调度优先级
* @return 已构造并尝试写入的稳定任务事实
*/
public ThirdPaymentTaskEntity ensureRelationBind(Long tenantId, Long relationId, LocalDateTime nextRunTime, int priority) {
tenantId = tenantId == null ? TenantContextHolder.getTenantId() : tenantId;
if (tenantId == null || relationId == null) {
throw new IllegalArgumentException("分账关系任务租户和关系主键不能为空");
}
LocalDateTime now = LocalDateTime.now();
String taskKey = "FZ_RELATION_BIND:" + relationId;
ThirdPaymentTaskEntity task = new ThirdPaymentTaskEntity();
task.setId(IdWorker.getId());
task.setTenantId(tenantId);
task.setTaskType(ThirdPaymentTaskTypeEnum.FZ_RELATION_BIND.getCode());
task.setTaskKey(taskKey);
task.setFzRelationId(relationId);
task.setStatus("READY");
task.setNextRunTime(nextRunTime == null ? now : nextRunTime);
task.setPriority(priority);
task.setAttemptCount(0);
task.setCreateTime(now);
task.setUpdateTime(now);
task.setDelFlag("0");
taskMapper.insertIgnore(task);
return task;
}
/**
* 为支付订单写入查单任务;唯一键冲突时保持既有任务,不重新生成支付号。
* @param tenantId 租户ID
@@ -10,16 +10,19 @@ import com.alipay.api.AlipayClient;
import com.alipay.api.AlipayConfig;
import com.alipay.api.DefaultAlipayClient;
import com.alipay.api.domain.AlipayTradeCreateModel;
import com.alipay.api.domain.SubMerchant;
import com.alipay.api.request.AlipaySystemOauthTokenRequest;
import com.alipay.api.request.AlipayTradeCreateRequest;
import com.alipay.api.response.AlipaySystemOauthTokenResponse;
import com.alipay.api.response.AlipayTradeCancelResponse;
import com.alipay.api.response.AlipayTradeCreateResponse;
import com.alipay.api.response.AlipayTradeQueryResponse;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
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;
@@ -31,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;
@@ -87,6 +91,10 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
*/
private final ThirdAppPlusAliPayService thirdAppPlusAliPayService;
/** 聚合子订单创建前校验支付宝直付通关系归属、绑定状态和抽佣比例。 */
@Autowired(required = false)
private ThirdMchFzRelationService mchFzRelationService;
/**
* 当前应用配置对应的支付宝客户端,延迟初始化并在请求间复用。
*/
@@ -138,6 +146,7 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
public ThirdAggregateCashierLaunch createPay(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting,
ThirdAggregateAlipayOauthIdentity identity) {
MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySetting(paySetting);
validateFzRelation(context, paySetting);
ThirdPayOrderEntity payOrder = buildPayOrder(context, paySetting);
AlipayTradeCreateModel model = new AlipayTradeCreateModel();
@@ -146,6 +155,9 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
model.setSubject(SUBJECT);
model.setProductCode(PRODUCT_CODE);
model.setTimeExpire(LocalDateTimeUtil.format(context.getExpireTime(), DatePattern.NORM_DATETIME_PATTERN));
SubMerchant subMerchant = new SubMerchant();
subMerchant.setMerchantId(aliPaySetting.getSubMchid());
model.setSubMerchant(subMerchant);
if (StrUtil.isNotBlank(identity.getOpenId())) {
model.setBuyerOpenId(identity.getOpenId());
} else {
@@ -274,6 +286,9 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
payOrder.setUserId(context.getUserId());
payOrder.setMchId(context.getMchId());
payOrder.setPrice(context.getAmount());
payOrder.setFzRelationId(context.getFzRelationId());
payOrder.setEnableFz(context.getFzRelationId() != null
? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode());
payOrder.setPayNo(context.getPayNo());
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType());
payOrder.setExpireTime(context.getExpireTime());
@@ -309,9 +324,23 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
throw new RuntimeException("商户支付宝支付配置不完整");
}
MchPaySettingEntity.AliPaySetting setting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
if (setting == null || StrUtil.isBlank(setting.getSubMchid())) {
throw new RuntimeException("商户支付宝直付通二级商户标识未配置");
}
return setting;
}
/** 聚合支付底层再次校验分佣关系,防止绕过编排层直接创建无关系直付通订单。 */
private void validateFzRelation(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting) {
if (context == null || context.getFzRelationId() == null) {
return;
}
if (mchFzRelationService == null) {
throw new RuntimeException("支付宝平台分佣关系服务不可用");
}
mchFzRelationService.requirePaymentRelation(context.getFzRelationId(), paySetting);
}
/**
* 将整数人民币分精确转换为两位小数人民币元字符串。
*
@@ -22,6 +22,7 @@ import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateAlipayOauthIdentity;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch;
@@ -30,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;
@@ -189,6 +189,9 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
*/
private final ThirdPayOrderService thirdPayOrderService;
/** 聚合平台选择后验证并固定默认平台分佣关系。 */
private final ThirdMchFzRelationService mchFzRelationService;
/**
* 钱包商户远程服务,用于读取当前租户收款商户及启用支付配置。
*/
@@ -569,9 +572,13 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
try {
ThirdAggregateCashierLaunch launch;
if (ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.ALI_PAY)) {
launch = aggregateAliPayService.createPay(context, getRequiredAliPaySetting(merchant), alipayIdentity);
MchPaySettingVo setting = getRequiredAliPaySetting(merchant);
setting = applyDefaultPaymentRelation(setting, context);
launch = aggregateAliPayService.createPay(context, setting, alipayIdentity);
} else {
launch = aggregateWechatPayService.createPay(context, getRequiredWechatPaySetting(merchant), payerIdentity);
MchPaySettingVo setting = getRequiredWechatPaySetting(merchant);
setting = applyDefaultPaymentRelation(setting, context);
launch = aggregateWechatPayService.createPay(context, setting, payerIdentity);
}
if (!aggregatePaySessionService.markPaying(session.getId(), payNo)) {
ThirdPayOrderEntity latestPayOrder = thirdPayOrderService.getByPayNo(payNo);
@@ -1043,7 +1050,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
}
/**
* 选择已启用且包含 app_auth_token 的支付宝商户配置。
* 选择已启用的支付宝支付配置;直付通渠道参数在选定平台后从默认关系解析。
*
* @param merchant 收款商户详情
* @return 当前商户支付宝配置
@@ -1053,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.getAppAuthToken())) {
throw new RuntimeException("商户支付宝授权Token未配置");
}
return setting;
}
/**
* 选择已启用且包含 sub_mchid 的微信商户配置。
* 选择已启用的微信支付配置;子商户号在选定平台后从默认关系解析。
*
* @param merchant 收款商户详情
* @return 当前商户微信支付配置
@@ -1074,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;
}
@@ -1205,6 +1204,19 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
return context;
}
/**
* 聚合支付选定平台后解析默认关系,并生成只属于当前子订单的渠道配置快照。
*
* @param setting 已确认启用的支付配置
* @param context 聚合子订单创建上下文
* @return 由关系事实生成的支付配置快照
*/
private MchPaySettingVo applyDefaultPaymentRelation(MchPaySettingVo setting, ThirdAggregatePayCreateContext context) {
ThirdMchFzRelationEntity relation = mchFzRelationService.requireBound(setting);
context.setFzRelationId(relation.getId());
return mchFzRelationService.createPaymentSettingSnapshot(setting, relation);
}
/**
* 获取聚合二维码和状态查询共用的公网基础地址。
*
@@ -9,6 +9,7 @@ import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateWechatPayService;
@@ -23,10 +24,12 @@ import com.wechat.pay.java.service.partnerpayments.jsapi.model.Payer;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.PrepayRequest;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.PrepayWithRequestPaymentResponse;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.QueryOrderByOutTradeNoRequest;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.SettleInfo;
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;
@@ -89,6 +92,10 @@ public class ThirdAggregateWechatPayServiceImpl implements ThirdAggregateWechatP
*/
private final ThirdPayOrderService thirdPayOrderService;
/** 聚合子订单创建前校验分账关系仍属于当前商户支付配置。 */
@Autowired(required = false)
private ThirdMchFzRelationService mchFzRelationService;
/**
* 调用服务商统一公众号 OAuth 接口换取 sp_openid。
*
@@ -161,6 +168,16 @@ public class ThirdAggregateWechatPayServiceImpl implements ThirdAggregateWechatP
request.setNotifyUrl(wechatConfig.getBaseCallbackUrl() + wechatConfig.getPayCallbackUrl());
request.setAmount(amount);
request.setPayer(payer);
SettleInfo settleInfo = new SettleInfo();
boolean enableFz = context.getFzRelationId() != null;
if (enableFz) {
if (mchFzRelationService == null) {
throw new RuntimeException("微信平台分佣关系服务不可用");
}
mchFzRelationService.requirePaymentRelation(context.getFzRelationId(), paySetting);
}
settleInfo.setProfitSharing(enableFz);
request.setSettleInfo(settleInfo);
payOrder.setPayRep(buildRequestSnapshot(context, wechatPaySetting).toString());
PrepayWithRequestPaymentResponse response;
@@ -286,6 +303,10 @@ public class ThirdAggregateWechatPayServiceImpl implements ThirdAggregateWechatP
payOrder.setUserId(context.getUserId());
payOrder.setMchId(context.getMchId());
payOrder.setPrice(context.getAmount());
payOrder.setFzRelationId(context.getFzRelationId());
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());
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType());
payOrder.setExpireTime(context.getExpireTime());
@@ -48,12 +48,14 @@ 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;
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.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
@@ -62,6 +64,7 @@ import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService;
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.vo.appplus.pay.alipay.ThirdALiPayCallbackVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
@@ -76,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;
@@ -153,11 +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;
@@ -198,58 +212,11 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
return alipayClient;
}
/**
* 按商户支付快照选择平台支付宝账号。accountKey 只作为平台配置键,
* 不接受客户端传入的应用密钥或账号内容。
*
* @param aliPaySetting 商户支付宝配置快照
* @return 对应支付宝客户端
*/
private AlipayClient getAlipayClient(MchPaySettingEntity.AliPaySetting aliPaySetting) {
AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(aliPaySetting);
if (config == appPlusPayConfig.getAliPayConfig()) {
return getAlipayClient();
}
try {
AlipayConfig alipayConfig = new AlipayConfig();
alipayConfig.setServerUrl("https://openapi.alipay.com/gateway.do");
alipayConfig.setAppId(config.getAppId());
alipayConfig.setPrivateKey(config.getAppPrivateKey());
alipayConfig.setFormat("json");
alipayConfig.setAlipayPublicKey(config.getAlipayPublicKey());
alipayConfig.setCharset("UTF-8");
alipayConfig.setSignType("RSA2");
return new DefaultAlipayClient(alipayConfig);
} catch (Exception exception) {
log.error("初始化命名支付宝客户端失败,accountKey:{}", aliPaySetting == null ? null : aliPaySetting.getAccountKey(), exception);
throw new RuntimeException("支付宝客户端初始化失败");
}
}
/**
* 解析商户配置引用的平台支付宝账号。
*
* @param aliPaySetting 商户支付宝配置
* @return 默认或命名支付宝账号配置
*/
private AppPlusPayConfig.AliPayConfig resolveAliPayConfig(MchPaySettingEntity.AliPaySetting aliPaySetting) {
AppPlusPayConfig.AliPayConfig defaultConfig = appPlusPayConfig.getAliPayConfig();
if (defaultConfig == null || StrUtil.isBlank(aliPaySetting == null ? null : aliPaySetting.getAccountKey())) {
return defaultConfig;
}
AppPlusPayConfig.AliPayConfig account = defaultConfig.getAccounts() == null
? null : defaultConfig.getAccounts().get(aliPaySetting.getAccountKey());
if (account == null) {
throw new RuntimeException("支付宝平台账号不存在");
}
return account;
}
/**
* 仅在配置了代调用授权令牌时加入公共参数,避免把空 token 发送给支付宝。
*
* @param textParamWriter 支付宝请求的附加文本参数写入方法
* @param aliPaySetting 商户支付宝配置
* @param aliPaySetting 商户支付宝配置
*/
private void putAppAuthToken(BiConsumer<String, String> textParamWriter,
MchPaySettingEntity.AliPaySetting aliPaySetting) {
@@ -273,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) {
@@ -285,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("支付宝支付回调地址未配置");
}
@@ -311,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("支付宝退款回调地址未配置");
}
@@ -342,7 +303,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 按商户订单号查询支付宝交易状态。
*
* @param payNo 第三方支付服务生成的商户订单号
* @param payNo 第三方支付服务生成的商户订单号
* @param settingJson 创建订单时保存的商户配置快照
* @return 支付宝查询响应;解析、网络或 SDK 异常时返回 {@code null}
*/
@@ -355,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());
}
@@ -365,8 +326,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 调用支付宝当面付预创建接口并保存订单及不含授权令牌的请求、响应快照。
*
* @param dto 已生成支付订单号的内部支付参数
* @param setting 当前商户启用的支付宝支付配置
* @param dto 已生成支付订单号的内部支付参数
* @param setting 当前商户启用的支付宝支付配置
* @param expireTime 服务端二维码失效时间
* @return 支付订单号、二维码内容串和服务端过期时间
*/
@@ -375,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());
@@ -384,6 +350,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
model.setSubject(ORDER_CODE_SUBJECT);
model.setProductCode(ORDER_CODE_PRODUCT_CODE);
model.setTimeExpire(LocalDateTimeUtil.format(expireTime, DatePattern.NORM_DATETIME_PATTERN));
SubMerchant subMerchant = new SubMerchant();
subMerchant.setMerchantId(aliPaySetting.getSubMchid());
model.setSubMerchant(subMerchant);
payOrder.setPayRep(JSONUtil.toJsonStr(model));
request.setBizModel(model);
@@ -391,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());
@@ -424,7 +393,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 调用支付宝撤销接口;异常时保留未支付状态,由上层定时任务继续重试。
*
* @param payNo 第三方支付服务生成的商户订单号
* @param payNo 第三方支付服务生成的商户订单号
* @param settingJson 创建订单时保存的商户支付配置快照
* @return 支付宝撤销响应,调用异常时返回 {@code null}
*/
@@ -437,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;
@@ -468,21 +437,32 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
payOrder.setExpireTime(now.plusMinutes(15));
payOrder.setMchId(dto.getMchId());
payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting));
if (setting.getFzRatio() == null || setting.getFzRatio() <= 0 || setting.getFzRatio() > 100) {
throw new RuntimeException("支付宝直付通平台抽佣比例必须大于0且不超过100%");
}
payOrder.setFzRelationId(dto.getFzRelationId());
validateFzRatioAndRelation(dto, setting);
if (dto.getAmount() == null || dto.getAmount() <= 10) {
throw new RuntimeException("支付宝直付通支付金额必须大于10分");
}
calculateFzAmount(dto.getAmount(), setting.getFzRatio());
payOrder.setEnableFz(com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode());
boolean enableFz = dto.getFzRelationId() != null;
if (enableFz) {
// 上层通常已经通过 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));
@@ -491,18 +471,23 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
model.setSubMerchant(subMerchant);
SettleInfo directPaySettleInfo = new SettleInfo();
directPaySettleInfo.setSettlePeriodTime("1d");
// 支付通必须填写`SettleDetailInfo`
ArrayList<SettleDetailInfo> settleDetailInfoList = new ArrayList<>();
SettleDetailInfo defaultSettleDetailInfo = new SettleDetailInfo();
defaultSettleDetailInfo.setTransInType("defaultSettle");
defaultSettleDetailInfo.setAmount(totalAmount);
settleDetailInfoList.add(defaultSettleDetailInfo);
directPaySettleInfo.setSettleDetailInfos(settleDetailInfoList);
model.setSettleInfo(directPaySettleInfo);
payOrder.setPayRep(JSONUtil.toJsonStr(model));
request.setBizModel(model);
putAppAuthToken(request::putOtherTextParam, aliPaySetting);
if (paymentOrderReservationService != null) {
paymentOrderReservationService.reserveAppPay(payOrder);
}
try {
AlipayTradeAppPayResponse response = getAlipayClient(aliPaySetting).sdkExecute(request);
AlipayTradeAppPayResponse response = getAlipayClient().sdkExecute(request);
String orderStr = response.getBody();
payOrder.setPayRes(orderStr);
return orderStr;
} catch (Exception e) {
@@ -549,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());
@@ -563,19 +548,20 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
: thirdFzOrderService.getByPayNo(thirdPayOrder.getPayNo());
if (fzOrder != null && ObjectUtil.equals(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_SUCCESS.getType())
&& fzOrder.getPrice() != null && fzOrder.getPrice() > 0) {
AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(aliPaySetting);
if (config == null || StrUtil.hasBlank(config.getRoyaltyTransIn(), config.getRoyaltyTransInType())) {
throw new RuntimeException("平台支付宝抽佣接收方未配置,无法退分账");
}
validateRoyaltyAccountType(config.getRoyaltyTransInType());
ThirdMchFzRelationEntity relation = mchFzRelationService == null ? null
: mchFzRelationService.getById(thirdPayOrder.getFzRelationId());
requireDirectPayRelation(relation, thirdPayOrder, aliPaySetting);
String receiverAccount = relation.getReceiverAccount();
String receiverType = relation.getReceiverType();
validateRoyaltyAccountType(receiverType);
int royaltyRefundAmount = BigDecimal.valueOf(fzOrder.getPrice())
.multiply(BigDecimal.valueOf(refundFee))
.divide(BigDecimal.valueOf(thirdPayOrder.getPrice()), 0, RoundingMode.DOWN).intValue();
if (royaltyRefundAmount > 0) {
OpenApiRoyaltyDetailInfoPojo refundRoyalty = new OpenApiRoyaltyDetailInfoPojo();
refundRoyalty.setRoyaltyType("transfer");
refundRoyalty.setTransOut(config.getRoyaltyTransIn());
refundRoyalty.setTransOutType(config.getRoyaltyTransInType());
refundRoyalty.setTransOut(receiverAccount);
refundRoyalty.setTransOutType(receiverType);
refundRoyalty.setAmount(fenToYuan(BigDecimal.valueOf(royaltyRefundAmount)));
refundRoyalty.setDesc("平台服务费退款");
model.setRefundRoyaltyParameters(List.of(refundRoyalty));
@@ -612,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())) {
@@ -655,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);
// 重提成功只表示支付宝受理;网络异常或系统错误的结果仍未知,下一轮必须继续查询。
@@ -692,7 +678,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
* <p>支付宝以 out_request_no 保证幂等;只有明确业务错误才把查询响应改写为失败,
* 网络、系统和限频错误继续保留退款中供下一轮恢复。</p>
*
* @param payOrder 退款中的支付订单
* @param payOrder 退款中的支付订单
* @param aliPaySetting 支付创建时的支付宝商户配置
* @return 支付宝重提响应;无法安全重提或调用结果未知时为空
*/
@@ -708,9 +694,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
}
AlipayTradeRefundRequest retryRequest = new AlipayTradeRefundRequest();
retryRequest.setBizModel(retryModel);
retryRequest.setNotifyUrl(resolveRefundNotifyUrl(aliPaySetting));
retryRequest.setNotifyUrl(resolveRefundNotifyUrl());
putAppAuthToken(retryRequest::putOtherTextParam, aliPaySetting);
AlipayTradeRefundResponse retryResponse = getAlipayClient(aliPaySetting).execute(retryRequest);
AlipayTradeRefundResponse retryResponse = getAlipayClient().execute(retryRequest);
ThirdPayOrderEntity update = new ThirdPayOrderEntity();
update.setRefundRes(retryResponse == null ? null : retryResponse.getBody());
thirdPayOrderService.update(update, new LambdaUpdateWrapper<ThirdPayOrderEntity>()
@@ -728,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) {
@@ -769,6 +755,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 预留支付宝直付通分账事实;外部请求必须由后续独立任务执行。
*
* @param payOrder 已确认支付成功的支付宝订单
* @return 已存在或新建的分账订单;订单尚未满足分账条件时为空
*/
@@ -795,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());
@@ -812,7 +802,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
* 补偿支付宝直付通结算、平台抽佣及已分账退款。支付宝此链路没有可替代本地幂等号的轮询结果,
* 因此对超时或系统异常重复提交相同请求,显式业务失败才进入失败状态。
*
* @param fzOrder 已持久化的分账订单
* @param fzOrder 已持久化的分账订单
* @param payOrder 对应的支付订单
*/
@Override
@@ -842,7 +832,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 执行并保存确认结算单阶段;相同阶段终态重复调用不会再次请求支付宝。
* @param fzOrder 分账订单
*
* @param fzOrder 分账订单
* @param payOrder 支付订单
* @return SUCCESS、PROCESSING 或 FAILED
*/
@@ -877,7 +868,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 提交平台抽佣单阶段;提交成功只记录 SUBMITTED,最终成功由查询阶段裁定。
* @param fzOrder 分账订单
*
* @param fzOrder 分账订单
* @param payOrder 支付订单
* @return SUBMITTED、PROCESSING 或 FAILED
*/
@@ -919,7 +911,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 查询平台抽佣最终结果;提交响应成功本身不能进入最终分账成功。
* @param fzOrder 分账订单
*
* @param fzOrder 分账订单
* @param payOrder 支付订单
* @return SUCCESS、PROCESSING 或 FAILED
*/
@@ -978,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());
@@ -986,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);
@@ -996,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);
@@ -1011,7 +1012,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
return aliPaySetting;
}
/** 将分账订单终态转换为支付宝阶段状态。 */
/**
* 将分账订单终态转换为支付宝阶段状态。
*/
private String resolvePersistedFzStatus(ThirdFzOrderEntity fzOrder) {
if (fzOrder == null) {
return "FAILED";
@@ -1025,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, "支付宝直付通配置无效"))
@@ -1035,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}
*/
@@ -1086,7 +1091,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 查询支付宝退款并同步退分账终态;退款查询至少由调度任务在发起十秒后执行。
*
* @param fzOrder 退分账中的分账订单
* @param fzOrder 退分账中的分账订单
* @param payOrder 对应退款中的支付订单
*/
private void pollAlipayFzRefund(ThirdFzOrderEntity fzOrder, ThirdPayOrderEntity payOrder) {
@@ -1126,7 +1131,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 调用支付宝确认结算接口并保存稳定请求号。
*
* @param payOrder 支付订单
* @param payOrder 支付订单
* @param aliPaySetting 商户支付宝配置
* @return 确认结算阶段快照
*/
@@ -1155,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));
@@ -1187,24 +1192,29 @@ 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();
AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(aliPaySetting);
if (config == null || StrUtil.hasBlank(config.getRoyaltyTransIn(), config.getRoyaltyTransInType())) {
ThirdMchFzRelationEntity relation = mchFzRelationService == null ? null : mchFzRelationService.getById(payOrder.getFzRelationId());
try {
requireDirectPayRelation(relation, payOrder, aliPaySetting);
} catch (RuntimeException exception) {
stage.set("status", "FAILED");
stage.set("error", "平台支付宝抽佣接收方未配置");
stage.set("error", exception.getMessage());
return stage;
}
String receiverAccount = relation.getReceiverAccount();
String receiverType = relation.getReceiverType();
String receiverName = relation.getReceiverName();
try {
validateRoyaltyAccountType(config.getRoyaltyTransInType());
validateRoyaltyAccountType(receiverType);
} catch (RuntimeException exception) {
stage.set("status", "FAILED");
stage.set("error", exception.getMessage());
@@ -1212,9 +1222,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
}
OpenApiRoyaltyDetailInfoPojo detail = new OpenApiRoyaltyDetailInfoPojo();
detail.setRoyaltyType("transfer");
detail.setTransIn(config.getRoyaltyTransIn());
detail.setTransInType(config.getRoyaltyTransInType());
detail.setTransInName(config.getRoyaltyTransInName());
detail.setTransIn(receiverAccount);
detail.setTransInType(receiverType);
detail.setTransInName(receiverName);
detail.setAmount(fenToYuan(BigDecimal.valueOf(fzOrder.getPrice())));
detail.setDesc("平台服务费");
AlipayTradeOrderSettleModel model = new AlipayTradeOrderSettleModel();
@@ -1232,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));
@@ -1248,13 +1258,62 @@ 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 aliPaySetting 支付订单创建时保存的支付宝配置快照
*/
private void requireDirectPayRelation(ThirdMchFzRelationEntity relation,
ThirdPayOrderEntity payOrder,
MchPaySettingEntity.AliPaySetting aliPaySetting) {
AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.getAliPayConfig();
if (relation == null || !"0".equals(relation.getDelFlag())
|| relation.getBoundTime() == null
|| payOrder == null
|| !ObjectUtil.equals(relation.getMchId(), payOrder.getMchId())
|| !ObjectUtil.equals(relation.getPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY.getType())
|| config == null
|| !ObjectUtil.equals(relation.getChannelAppId(), config.getAppId())
|| !ObjectUtil.equals(relation.getChannelSubMchId(), aliPaySetting == null ? null : aliPaySetting.getSubMchid())
|| relation.getFzRatio() == null || !Double.isFinite(relation.getFzRatio())
|| relation.getFzRatio() <= 0 || relation.getFzRatio() > 100
|| StrUtil.hasBlank(relation.getReceiverAccount(), relation.getReceiverType())) {
throw new IllegalStateException("支付宝支付订单的固定直付通分佣关系不存在或与二级商户不一致");
}
validateRoyaltyAccountType(relation.getReceiverType());
}
/**
* 校验新支付只能使用当前支付配置唯一默认且已绑定的关系。
*
* <p>历史关系允许旧订单退款和退分账,但绝不能继续承接新支付;因此支付创建校验
* 比历史订单分账校验额外要求关系状态、默认标记和支付配置主键完全一致。</p>
*
* @param relation 内部关系主键查到的关系事实
* @param payOrder 即将创建的支付订单
* @param aliPaySetting 当前支付宝配置
* @param setting 当前商户支付配置
*/
private void requirePaymentCreateRelation(ThirdMchFzRelationEntity relation,
ThirdPayOrderEntity payOrder,
MchPaySettingEntity.AliPaySetting aliPaySetting,
MchPaySettingVo setting) {
requireDirectPayRelation(relation, payOrder, aliPaySetting);
if (!ObjectUtil.equals(relation.getId(), payOrder.getFzRelationId())
|| !ObjectUtil.equals(relation.getPaySettingId(), setting == null ? null : setting.getId())
|| !ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(relation.getStatus())
|| !com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode().equals(relation.getIsDefault())) {
throw new IllegalStateException("支付宝新支付只能使用当前支付配置默认且已绑定的分佣关系");
}
}
@@ -1278,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());
@@ -1295,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);
@@ -1314,6 +1374,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 将支付宝抽佣查询结果映射为本地阶段状态。
*
* @param response 支付宝抽佣查询响应
* @return SUCCESS、PROCESSING 或 FAILED
*/
@@ -1337,7 +1398,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
return anyFailed ? "FAILED" : "PROCESSING";
}
/** 返回抽佣查询中最适合监控聚合的外部状态。 */
/**
* 返回抽佣查询中最适合监控聚合的外部状态。
*/
private String resolveRoyaltyExternalStatus(AlipayTradeOrderSettleQueryResponse response) {
if (response == null) {
return "EMPTY_RESPONSE";
@@ -1352,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";
@@ -1370,7 +1435,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 将支付宝平台抽佣成功状态原子写入支付订单,防止重复轮询累加分账金额。
*
* @param fzOrder 分账订单
* @param fzOrder 分账订单
* @param payOrder 支付订单
*/
private void markDirectPayFzSuccess(ThirdFzOrderEntity fzOrder, ThirdPayOrderEntity payOrder) {
@@ -1406,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())) {
@@ -1551,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;
@@ -1605,12 +1670,13 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 构建订单码支付的待支付订单,完整记录业务、平台、金额、配置和过期时间。
*
* @param dto 内部支付参数
* @param setting 商户支付宝配置
* @param dto 内部支付参数
* @param setting 商户支付宝配置
* @param expireTime 服务端二维码失效时间
* @return 可直接持久化的待支付订单
*/
private ThirdPayOrderEntity buildOrderCodePayOrder(ThirdAppPlusPayDto dto, MchPaySettingVo setting, LocalDateTime expireTime) {
validateFzRatioAndRelation(dto, setting);
ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity();
payOrder.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType());
payOrder.setBizId(dto.getBizId());
@@ -1622,10 +1688,30 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
payOrder.setPayNo(dto.getOutTradeNo());
payOrder.setMchId(dto.getMchId());
payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting));
payOrder.setFzRelationId(dto.getFzRelationId());
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 (dto == null || dto.getFzRelationId() == null) {
return;
}
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());
}
/**
* 解析当前商户支付宝配置;授权令牌允许为空,账号选择和直付通标识由具体调用继续校验。
*
@@ -1713,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}
*/
@@ -1751,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;
@@ -1788,7 +1866,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
/**
* 校验聚合会话与实际第三方子订单的业务、商户和金额快照一致。
*
* @param session {@code pay_no} 绑定的聚合支付会话
* @param session {@code pay_no} 绑定的聚合支付会话
* @param payOrder 支付宝回调对应的第三方子订单
* @return 关键业务字段完全一致时返回 {@code true}
*/
@@ -50,7 +50,6 @@ import com.bsd.pjcloud.wallet.api.constant.WalleCardStatusEnum;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargePaySuccess;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeRefundSuccess;
import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargePackageEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
@@ -65,6 +64,7 @@ import com.wechat.pay.java.service.refund.model.RefundNotification;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
@@ -233,6 +233,10 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
*/
private final RemoteHardOrderService remoteHardOrderService;
/** 支付创建前验证并固定商户平台分佣关系。 */
@Autowired(required = false)
private ThirdMchFzRelationService mchFzRelationService;
/**
* 小程序登录
*/
@@ -410,8 +414,7 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
// 微信小程序
if (ObjectUtil.equals(dto.getMiniPlatformType().getType(), AppPlusPlatformTypeEnum.WECHAT.getType())) {
MchPaySettingVo paySetting = getEnabledWechatPaySetting(mchManage);
MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class);
appPlusPayDto.setSubMchid(wechatPaySetting.getSubMchid());
paySetting = applyDefaultPaymentRelation(paySetting, appPlusPayDto);
Map<String, Object> appPlusPayParams;
try {
@@ -428,8 +431,7 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
// 支付宝
else if (ObjectUtil.equals(dto.getMiniPlatformType().getType(), AppPlusPlatformTypeEnum.ALI_PAY.getType())) {
MchPaySettingVo paySetting = getEnabledAliPaySetting(mchManage);
MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
appPlusPayDto.setSubMchid(aliPaySetting.getSubMchid());
paySetting = applyDefaultPaymentRelation(paySetting, appPlusPayDto);
String orderStr;
try {
@@ -586,11 +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.setSubMchid(wechatPaySetting.getSubMchid());
paySetting = applyDefaultPaymentRelation(paySetting, appPlusPayDto);
String wxOutTradeNo = thirdAppPlusWechatMicropayService.micropay(appPlusPayDto, paySetting);
if (ObjectUtil.isNotEmpty(wxOutTradeNo)) {
@@ -603,8 +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.setSubMchid(aliPaySetting.getSubMchid());
applyDefaultPaymentRelation(paySetting, appPlusPayDto);
}
// 不支持的支付类型
else {
@@ -730,10 +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);
paySetting = applyDefaultPaymentRelation(paySetting, payDto);
validateAliPayApplicationConfig();
return thirdMiniAppAliPayService.precreate(payDto, paySetting, expireTime);
}
MchPaySettingVo paySetting = getEnabledWechatPaySetting(merchant);
paySetting = applyDefaultPaymentRelation(paySetting, payDto);
validateWechatApplicationConfig();
return thirdAppPlusWechatNativePayService.precreate(payDto, paySetting, expireTime);
}
@@ -741,6 +743,23 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
/**
* 校验订单码支付首版允许的业务、平台、金额和商户参数。
*/
/**
* 解析默认关系并生成订单级渠道配置快照,二级商户与支付宝授权均不得再从支付配置 JSON 读取。
*
* @param paySetting 已由商户服务确认启用的支付配置
* @param payDto 即将提交渠道服务的内部支付参数
* @return 由关系事实生成的独立支付配置快照
*/
private MchPaySettingVo applyDefaultPaymentRelation(MchPaySettingVo paySetting, ThirdAppPlusPayDto payDto) {
if (mchFzRelationService == null) {
throw new IllegalStateException("支付平台分佣关系服务不可用");
}
com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity relation = mchFzRelationService.requireBound(paySetting);
payDto.setFzRelationId(relation.getId());
payDto.setSubMchid(relation.getChannelSubMchId());
return mchFzRelationService.createPaymentSettingSnapshot(paySetting, relation);
}
private void validateOrderCodePayRequest(ThirdOrderCodePayDto dto) {
if (dto == null) {
throw new RuntimeException("订单码支付参数不能为空");
@@ -858,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;
}
@@ -884,21 +892,9 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
.filter(item -> ObjectUtil.equals(item.getEnable(), YesNoEnum.YES.getCode()))
.findFirst()
.orElse(null);
if (paySetting == null || StrUtil.isBlank(paySetting.getSettingJson())) {
if (paySetting == null) {
throw new RuntimeException("商户未启用微信支付配置");
}
try {
MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean(
paySetting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class);
if (wechatPaySetting == null || StrUtil.isBlank(wechatPaySetting.getSubMchid())) {
throw new RuntimeException("商户微信子商户号未配置");
}
} catch (RuntimeException exception) {
if ("商户微信子商户号未配置".equals(exception.getMessage())) {
throw exception;
}
throw new RuntimeException("商户微信支付配置无效");
}
return paySetting;
}
@@ -14,6 +14,7 @@ import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatMicropayService;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.utils.pay.PayForUtil;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
@@ -27,6 +28,7 @@ import com.wechat.pay.java.service.partnerpayments.app.model.Transaction;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
@@ -53,6 +55,10 @@ public class ThirdAppPlusWechatMicropayServiceImpl implements ThirdAppPlusWechat
/** 支付成功后写入数据库分账任务,不创建进程内延迟线程。 */
private final ThirdPaymentTaskEnqueuer paymentTaskEnqueuer;
/** 付款码创建时校验支付订单固定的关系归属、状态和比例。 */
@Autowired(required = false)
private ThirdMchFzRelationService mchFzRelationService;
/**
* 付款码支付
*/
@@ -71,6 +77,7 @@ public class ThirdAppPlusWechatMicropayServiceImpl implements ThirdAppPlusWechat
payOrder.setPayNo(dto.getOutTradeNo());
payOrder.setMchId(dto.getMchId());
payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting));
payOrder.setFzRelationId(dto.getFzRelationId());
payOrder.setIsBizCallback(YesNoEnum.YES.getCode());
WxPayMicropayRequest request = new WxPayMicropayRequest();
@@ -85,8 +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) {
if (dto.getAmount() == null || dto.getAmount() <= 10) {
throw new RuntimeException("微信分账支付金额必须大于10分");
}
if (mchFzRelationService == null) {
throw new RuntimeException("微信平台分佣关系服务不可用");
}
mchFzRelationService.requirePaymentRelation(dto.getFzRelationId(), setting);
request.setProfitSharing("Y");
payOrder.setEnableFz(YesNoEnum.YES.getCode());
} else {
@@ -8,7 +8,9 @@ import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatNativePayService;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
@@ -19,11 +21,13 @@ import com.wechat.pay.java.service.partnerpayments.nativepay.model.Amount;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.CloseOrderRequest;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.PrepayRequest;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.PrepayResponse;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.SettleInfo;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.QueryOrderByOutTradeNoRequest;
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;
@@ -71,6 +75,10 @@ public class ThirdAppPlusWechatNativePayServiceImpl implements ThirdAppPlusWecha
*/
private final ThirdPayOrderService thirdPayOrderService;
/** 支付创建时校验关系主键归属、绑定状态和抽佣比例。 */
@Autowired(required = false)
private ThirdMchFzRelationService mchFzRelationService;
/**
* 创建微信服务商 Native 订单,只有 SDK 返回非空 {@code code_url} 才视为成功。
*
@@ -98,6 +106,16 @@ public class ThirdAppPlusWechatNativePayServiceImpl implements ThirdAppPlusWecha
request.setTimeExpire(formatWechatTime(expireTime));
request.setNotifyUrl(wechatConfig.getBaseCallbackUrl() + wechatConfig.getPayCallbackUrl());
request.setAmount(amount);
SettleInfo settleInfo = new SettleInfo();
boolean enableFz = dto.getFzRelationId() != null;
if (enableFz) {
if (mchFzRelationService == null) {
throw new RuntimeException("微信平台分佣关系服务不可用");
}
mchFzRelationService.requirePaymentRelation(dto.getFzRelationId(), setting);
}
settleInfo.setProfitSharing(enableFz);
request.setSettleInfo(settleInfo);
payOrder.setPayRep(JSONUtil.toJsonStr(request));
PrepayResponse response;
@@ -200,6 +218,10 @@ public class ThirdAppPlusWechatNativePayServiceImpl implements ThirdAppPlusWecha
payOrder.setPayNo(dto.getOutTradeNo());
payOrder.setMchId(dto.getMchId());
payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting));
payOrder.setFzRelationId(dto.getFzRelationId());
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;
}
@@ -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 支付,子商户号和分账比例只接受服务端商户配置快照。
*
@@ -119,6 +124,7 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService
payOrder.setExpireTime(expireTime);
payOrder.setMchId(dto.getMchId());
payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting));
payOrder.setFzRelationId(dto.getFzRelationId());
try {
String description = "饭卡钱包充值" + fenToYuan(BigDecimal.valueOf(dto.getAmount())) + "元";
// 支付金额
@@ -129,7 +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) {
settleInfo.setProfitSharing(true);
payOrder.setEnableFz(YesNoEnum.YES.getCode());
} else {
@@ -194,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;
@@ -8,8 +8,10 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
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.ThirdFzOrderMapper;
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayOrderMapper;
@@ -17,6 +19,7 @@ import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatFzService;
import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.wechat.pay.java.service.profitsharing.ProfitsharingService;
import com.wechat.pay.java.service.profitsharing.model.*;
@@ -56,6 +59,9 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ
*/
private final ThirdPaymentTaskEnqueuer paymentTaskEnqueuer;
/** 支付订单创建时固定的平台分佣关系服务;分账和退分账必须依赖该历史关系事实。 */
private final ThirdMchFzRelationService mchFzRelationService;
/**
* 发起分账
*/
@@ -84,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();
@@ -100,12 +101,21 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ
fzOrderEntity.setStatus(ThirdFzOrderStatusEnum.FZ_UNPAID.getType());
AppPlusPayConfig.WechatConfig wechatConfig = appPlusPayConfig.getWechatConfig();
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分且不能超过订单金额");
}
@@ -120,10 +130,10 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ
createDto.setOutOrderNo(outOrderNo);
CreateOrderReceiver zaxReceiver = new CreateOrderReceiver();
zaxReceiver.setType("MERCHANT_ID");
zaxReceiver.setAccount(wechatConfig.getMerchantId());
zaxReceiver.setName("浙江资安信信息科技有限公司");
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());
@@ -178,10 +188,10 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ
return null;
}
AppPlusPayConfig.WechatConfig wechatConfig = appPlusPayConfig.getWechatConfig();
MchPaySettingEntity mchPaySetting = JSONUtil.toBean(orderEntity.getMchPaySettingJson(), MchPaySettingEntity.class);
MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean(mchPaySetting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class);
ThirdMchFzRelationEntity relation = mchFzRelationService.getById(orderEntity.getFzRelationId());
requireWechatRelation(relation, orderEntity, wechatPaySetting.getSubMchid(), appPlusPayConfig.getWechatConfig());
String outOrderNo = "refund_" + fzOrderDetail.getFzNo();
@@ -189,9 +199,9 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ
returnDto.setSubMchid(wechatPaySetting.getSubMchid());
returnDto.setOutOrderNo(fzOrderDetail.getFzNo());
returnDto.setOutReturnNo(outOrderNo);
returnDto.setReturnMchid(wechatConfig.getMerchantId());
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());
@@ -439,6 +449,30 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ
}
}
/**
* 校验支付订单固定的微信分账关系;历史关系允许为 SUPERSEDED,但不能缺失、软删除或指向其他子商户。
*
* @param relation 支付创建时保存的关系快照
* @param payOrder 关系所属支付订单
* @param subMchid 支付订单快照中的微信子商户号
* @param wechatConfig 当前服务商调用配置;AppID 必须与历史关系一致
*/
private void requireWechatRelation(ThirdMchFzRelationEntity relation, ThirdPayOrderEntity payOrder,
String subMchid, AppPlusPayConfig.WechatConfig wechatConfig) {
if (relation == null || !"0".equals(relation.getDelFlag())
|| relation.getBoundTime() == null
|| payOrder == null
|| !ObjectUtil.equals(relation.getMchId(), payOrder.getMchId())
|| !ObjectUtil.equals(relation.getPlatformType(), AppPlusPlatformTypeEnum.WECHAT.getType())
|| wechatConfig == null
|| !ObjectUtil.equals(relation.getChannelAppId(), wechatConfig.getAppPlatformAppId())
|| !ObjectUtil.equals(relation.getChannelSubMchId(), subMchid)
|| !ObjectUtil.equals(relation.getReceiverType(), ReceiverType.MERCHANT_ID.name())
|| StrUtil.isBlank(relation.getReceiverAccount())) {
throw new IllegalStateException("微信支付订单的固定分账关系不存在或与子商户不一致");
}
}
/**
* 合并微信平台抽佣阶段快照,保留首次请求并持续更新最近查询时间、外部状态和错误。
*
@@ -570,8 +604,8 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ
}
// 分账描述,最多80个字
private String getDescription(MchPaySettingEntity mchPaySetting, Long amount) {
return "商户id:" + mchPaySetting.getMchId() + "分账" + mchPaySetting.getFzRatio() + "%即" + fenToYuan(BigDecimal.valueOf(amount)) + "元,到平台";
private String getDescription(MchPaySettingEntity mchPaySetting, Double fzRatio, Long amount) {
return "商户id:" + mchPaySetting.getMchId() + "分账" + fzRatio + "%即" + fenToYuan(BigDecimal.valueOf(amount)) + "元,到平台";
}
public String fenToYuan(BigDecimal fen) {
@@ -24,8 +24,7 @@ import java.util.List;
*/
@Service
@Validated
public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl<ThirdAggregatePaySessionMapper, ThirdAggregatePaySessionEntity>
implements ThirdAggregatePaySessionService {
public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl<ThirdAggregatePaySessionMapper, ThirdAggregatePaySessionEntity> implements ThirdAggregatePaySessionService {
/**
* 按聚合会话编号读取当前租户有效记录。
@@ -76,7 +75,7 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl<ThirdAggreg
* 查询同业务最新聚合会话,供创建幂等和跨二维码互斥使用。
*
* @param bizType 支付业务类型
* @param bizId 调用方业务订单唯一标识
* @param bizId 调用方业务订单唯一标识
* @return 按创建时间倒序的第一条记录;参数无效或不存在时返回 {@code null}
*/
@Override
@@ -94,9 +93,9 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl<ThirdAggreg
/**
* 使用等待扫码状态作为 CAS 条件锁定平台和付款人,防止支付宝、微信同时创建可支付订单。
*
* @param sessionId 聚合会话主键
* @param platform 首个扫码选定的平台
* @param payNo 预分配的第三方子订单号
* @param sessionId 聚合会话主键
* @param platform 首个扫码选定的平台
* @param payNo 预分配的第三方子订单号
* @param payerIdentityHash 付款人身份摘要
* @return 仅一个竞争请求会返回 {@code true}
*/
@@ -121,7 +120,7 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl<ThirdAggreg
* 在第三方子订单保存成功后把会话从创建中推进为支付中。
*
* @param sessionId 聚合会话主键
* @param payNo 已成功保存的第三方子订单号
* @param payNo 已成功保存的第三方子订单号
* @return CAS 更新成功时返回 {@code true}
*/
@Override
@@ -138,7 +137,7 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl<ThirdAggreg
* 将明确失败的平台创建过程推进为创建失败终态。
*
* @param sessionId 聚合会话主键
* @param payNo 创建失败但已预分配的第三方子订单号
* @param payNo 创建失败但已预分配的第三方子订单号
* @return CAS 更新成功时返回 {@code true}
*/
@Override
@@ -159,7 +158,7 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl<ThirdAggreg
* 仅用于恢复已经验签并完成关键字段校验的延迟成功回调,确保真实扣款事实优先于本地超时判断。</p>
*
* @param sessionId 聚合会话主键
* @param status 目标终态
* @param status 目标终态
* @return CAS 更新成功时返回 {@code true}
*/
@Override
@@ -18,13 +18,14 @@ import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayOrderDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayOrderMapper;
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskMapper;
import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayOrderVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import lombok.RequiredArgsConstructor;
@@ -66,6 +67,10 @@ public class ThirdPayOrderServiceImpl extends ServiceImpl<ThirdPayOrderMapper, T
@Autowired(required = false)
private ThirdPaymentTaskMapper thirdPaymentTaskMapper;
/** 分账关系服务,用于按订单固定关系展示支付宝直付通二级商户标识。 */
@Autowired(required = false)
private ThirdMchFzRelationService thirdMchFzRelationService;
/**
* 按数据权限分页查询支付订单,并补充只用于管理端展示的关联信息。
*
@@ -182,25 +187,21 @@ public class ThirdPayOrderServiceImpl extends ServiceImpl<ThirdPayOrderMapper, T
}
/**
* 从订单创建时的支付宝配置快照读取直付通二级商户标识,不读取当前可变配置。
* 从订单创建时固定的关系事实读取直付通二级商户标识,不读取支付配置 JSON。
*
* @param order 支付订单视图
* @return 支付宝 smid;非支付宝订单、历史快照缺失或格式错误时为空
*/
private String readAlipaySmid(ThirdPayOrderVo order) {
if (!ObjectUtil.equals(order.getPayPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY.getType())
|| StrUtil.isBlank(order.getMchPaySettingJson())) {
|| order.getFzRelationId() == null || thirdMchFzRelationService == null) {
return null;
}
try {
MchPaySettingEntity setting = JSONUtil.toBean(order.getMchPaySettingJson(), MchPaySettingEntity.class);
if (setting == null || StrUtil.isBlank(setting.getSettingJson())) {
return null;
}
MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
return aliPaySetting == null ? null : aliPaySetting.getSubMchid();
ThirdMchFzRelationEntity relation = thirdMchFzRelationService.getById(order.getFzRelationId());
return relation == null ? null : relation.getChannelSubMchId();
} catch (Exception exception) {
log.warn("支付订单支付宝商户快照解析失败,payNo:{},errorType:{}", order.getPayNo(),
log.warn("支付订单支付宝关系读取失败,payNo:{},errorType:{}", order.getPayNo(),
exception.getClass().getSimpleName());
return null;
}
@@ -17,6 +17,7 @@ import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPaymentTaskDispatchVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
@@ -56,6 +57,10 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl<ThirdPaymentTaskMap
/** 实时任务写入器,用于对账发现状态缺口后复用原业务号重新排队。 */
private final ThirdPaymentTaskEnqueuer paymentTaskEnqueuer;
/** 商户分账关系绑定服务;可选注入仅用于兼容既有不启动 Spring 的单元测试。 */
@Autowired(required = false)
private ThirdMchFzRelationService mchFzRelationService;
/** Worker并发数,受配置限制,避免渠道调用耗尽 Web 线程。 */
@Value("${third.payment.task.worker-threads:16}")
private int workerThreads;
@@ -273,6 +278,13 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl<ThirdPaymentTaskMap
return TaskResult.success("REFUND_ACCEPTED");
}
return settleByRefundStatus(payOrder);
case FZ_RELATION_BIND:
if (mchFzRelationService == null) {
return TaskResult.failed("MISSING_SERVICE", "FZ_RELATION_SERVICE_MISSING", "商户分账关系服务不可用");
}
return mchFzRelationService.bind(task.getFzRelationId())
? TaskResult.success("BOUND")
: TaskResult.failed("BIND_FAILED", "FZ_RELATION_BIND_FAILED", "商户平台分佣关系绑定失败");
case FZ_CREATE:
payOrder = payOrderService.getById(task.getPayOrderId());
if (payOrder == null || !ThirdPayOrderStatusEnum.PAY_SUCCESS.getType().equals(payOrder.getStatus())) {
@@ -13,6 +13,7 @@
a.user_code,
a.mch_id,
a.mch_pay_setting_json,
a.fz_relation_id,
a.price,
a.fz_price,
a.status,
@@ -68,6 +68,21 @@
AND t.del_flag = '0')
ORDER BY p.update_time, p.id LIMIT #{limit}
</when>
<when test="taskType == 'FZ_RELATION_BIND'">
SELECT r.tenant_id, #{taskType} AS task_type,
CONCAT('FZ_RELATION_BIND:', r.id) AS task_key,
r.id AS fz_relation_id, 150 AS priority,
COALESCE(r.update_time, r.create_time, #{now}) AS next_run_time
FROM third_mch_fz_relation r
WHERE r.del_flag = '0' AND r.is_default = '1'
AND r.status IN ('PENDING_BIND', 'BINDING')
AND NOT EXISTS (
SELECT 1 FROM third_payment_task t
WHERE t.tenant_id = r.tenant_id
AND t.task_key = CONCAT('FZ_RELATION_BIND:', r.id)
AND t.del_flag = '0')
ORDER BY r.update_time, r.id LIMIT #{limit}
</when>
<when test="taskType == 'FZ_CREATE'">
SELECT p.tenant_id, #{taskType} AS task_type, CONCAT('FZ_CREATE:', p.pay_no) AS task_key,
p.id AS pay_order_id, p.pay_no, 90 AS priority,
@@ -235,11 +250,11 @@
<insert id="insertIgnore">
INSERT IGNORE INTO third_payment_task
(id, tenant_id, task_type, task_key, pay_order_id, fz_order_id, aggregate_session_id,
(id, tenant_id, task_type, task_key, pay_order_id, fz_order_id, fz_relation_id, aggregate_session_id,
pay_no, refund_no, fz_no, aggregate_no, status, next_run_time, priority, attempt_count,
create_time, update_time, del_flag)
VALUES
(#{id}, #{tenantId}, #{taskType}, #{taskKey}, #{payOrderId}, #{fzOrderId}, #{aggregateSessionId},
(#{id}, #{tenantId}, #{taskType}, #{taskKey}, #{payOrderId}, #{fzOrderId}, #{fzRelationId}, #{aggregateSessionId},
#{payNo}, #{refundNo}, #{fzNo}, #{aggregateNo}, #{status}, #{nextRunTime}, #{priority},
#{attemptCount}, #{createTime}, #{updateTime}, #{delFlag})
</insert>
@@ -328,4 +343,13 @@
last_error_code = NULL, last_error_message = NULL, update_time = #{nextRunTime}
WHERE id = #{id} AND status IN ('FAILED', 'ALERT', 'CANCELLED') AND del_flag = '0'
</update>
<update id="rearmByTaskKey">
UPDATE third_payment_task
SET status = 'READY', next_run_time = #{nextRunTime}, finished_time = NULL,
lease_owner = NULL, lease_token = NULL, lease_expire_time = NULL,
last_error_code = NULL, last_error_message = NULL, update_time = #{nextRunTime}
WHERE tenant_id = #{tenantId} AND task_key = #{taskKey}
AND status IN ('SUCCESS', 'FAILED', 'RETRY', 'ALERT', 'CANCELLED') AND del_flag = '0'
</update>
</mapper>
@@ -17,15 +17,19 @@ class ThirdPaymentTaskMapperSqlContractTest {
/** MyBatis 支付任务映射资源路径。 */
private static final String MAPPER_RESOURCE = "mapper/ThirdPaymentTaskMapper.xml";
/** MyBatis 支付订单映射资源路径,支付订单必须回读创建时固定的分账关系主键。 */
private static final String PAY_ORDER_MAPPER_RESOURCE = "mapper/ThirdPayOrderMapper.xml";
/** 支付任务表与高频业务扫描索引迁移相对项目根目录的路径。 */
private static final String PAYMENT_TASK_MIGRATION = "db/20260824_add_third_payment_task.sql";
/** thirdparty 支付统一迁移与高频业务扫描索引相对项目根目录的路径。 */
private static final String PAYMENT_TASK_MIGRATION = "db/20260825_third_payment_schema.sql";
/** 测试启动时读取的 Mapper XML。 */
private static String mapperXml;
/** 测试启动时读取的支付任务数据库迁移。 */
private static String migrationSql;
/** 测试启动时读取的支付订单 Mapper XML。 */
private static String payOrderMapperXml;
/**
* 从测试类路径读取本轮实际参与编译的 Mapper XML。
@@ -39,9 +43,23 @@ class ThirdPaymentTaskMapperSqlContractTest {
Assertions.assertNotNull(input, "支付任务 Mapper XML 必须进入运行时类路径");
mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
try (InputStream input = ThirdPaymentTaskMapperSqlContractTest.class.getClassLoader()
.getResourceAsStream(PAY_ORDER_MAPPER_RESOURCE)) {
Assertions.assertNotNull(input, "支付订单 Mapper XML 必须进入运行时类路径");
payOrderMapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
migrationSql = readProjectFile(PAYMENT_TASK_MIGRATION);
}
/**
* 支付订单管理查询必须投影关系主键,分账和退分账才能使用支付创建时的关系快照。
*/
@Test
void payOrderProjectionShouldKeepProfitSharingRelationId() {
Assertions.assertTrue(payOrderMapperXml.contains("a.fz_relation_id"),
"支付订单查询必须保留创建时固定的分账关系主键");
}
/**
* 验证领取动作必须同时受可领取原状态和到期时间约束,多个 thirdparty 实例只能有一个 CAS 成功。
*/
@@ -92,4 +92,30 @@ class ThirdPaymentTaskEnqueuerTest {
() -> Assertions.assertEquals(105, task.getPriority()),
() -> Assertions.assertNotNull(task.getNextRunTime()));
}
/** 验证关系绑定任务使用关系主键构造稳定键,并携带关系结构化字段。 */
@Test
void ensureRelationBindShouldUseStableRelationTaskKeyWithoutRearm() {
enqueuer.ensureRelationBind(9L, 901L, null, 150);
ArgumentCaptor<ThirdPaymentTaskEntity> captor = ArgumentCaptor.forClass(ThirdPaymentTaskEntity.class);
verify(taskMapper).insertIgnore(captor.capture());
ThirdPaymentTaskEntity task = captor.getValue();
Assertions.assertAll(
() -> Assertions.assertEquals("FZ_RELATION_BIND:901", task.getTaskKey()),
() -> Assertions.assertEquals(ThirdPaymentTaskTypeEnum.FZ_RELATION_BIND.getCode(), task.getTaskType()),
() -> Assertions.assertEquals(901L, task.getFzRelationId()),
() -> Assertions.assertEquals("READY", task.getStatus()));
}
/** 配置重新保存才允许显式唤醒原退避任务,普通支付流量不调用 rearm。 */
@Test
void enqueueRelationBindShouldRearmOnlyWhenExplicitlyRequested() {
enqueuer.enqueueRelationBind(9L, 902L, null, 150);
verify(taskMapper).insertIgnore(org.mockito.ArgumentMatchers.any(ThirdPaymentTaskEntity.class));
verify(taskMapper).rearmByTaskKey(org.mockito.ArgumentMatchers.eq(9L),
org.mockito.ArgumentMatchers.eq("FZ_RELATION_BIND:902"),
org.mockito.ArgumentMatchers.any());
}
}
@@ -16,6 +16,7 @@ import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import org.junit.jupiter.api.Assertions;
@@ -25,11 +26,13 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -57,6 +60,10 @@ class ThirdAggregateAliPayServiceImplTest {
@Mock
private ThirdAppPlusAliPayService thirdAppPlusAliPayService;
/** 支付宝聚合子订单关系校验服务 Mock。 */
@Mock
private ThirdMchFzRelationService mchFzRelationService;
/**
* 被测支付宝聚合服务,通过子类固定返回 Mock SDK 客户端。
*/
@@ -87,6 +94,7 @@ class ThirdAggregateAliPayServiceImplTest {
appPlusPayConfig.setAliPayConfig(aliPayConfig);
service = new TestableThirdAggregateAliPayService(appPlusPayConfig, thirdPayOrderService,
thirdAppPlusAliPayService, alipayClient);
ReflectionTestUtils.setField(service, "mchFzRelationService", mchFzRelationService);
context = new ThirdAggregatePayCreateContext();
context.setPayNo("AGP202608050001");
@@ -98,6 +106,7 @@ class ThirdAggregateAliPayServiceImplTest {
context.setExpireTime(LocalDateTime.now().plusMinutes(2));
MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting();
aliPaySetting.setSubMchid("smid-1001");
aliPaySetting.setAppAuthToken("merchant-auth-token");
paySetting = new MchPaySettingVo();
paySetting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting));
@@ -166,7 +175,9 @@ class ThirdAggregateAliPayServiceImplTest {
*/
@Test
void createPayShouldOmitEmptyAppAuthToken() throws Exception {
paySetting.setSettingJson(JSONUtil.toJsonStr(new MchPaySettingEntity.AliPaySetting()));
MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting();
aliPaySetting.setSubMchid("smid-1001");
paySetting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting));
AlipayTradeCreateResponse response = new AlipayTradeCreateResponse();
response.setCode("10000");
response.setTradeNo("2026000000000003");
@@ -180,6 +191,26 @@ class ThirdAggregateAliPayServiceImplTest {
Assertions.assertFalse(requestCaptor.getValue().getTextParams().containsKey("app_auth_token"));
}
/**
* 编排层携带的关系未绑定时,底层不得向支付宝创建交易。
*
* @throws Exception Mockito 校验的支付宝 SDK 方法声明受检异常
*/
@Test
void createPayShouldRejectUnboundCommissionRelation() throws Exception {
context.setFzRelationId(9001L);
when(mchFzRelationService.requirePaymentRelation(9001L, paySetting))
.thenThrow(new IllegalStateException("支付宝直付通平台分佣关系尚未绑定"));
RuntimeException exception = Assertions.assertThrows(RuntimeException.class,
() -> service.createPay(context, paySetting,
new ThirdAggregateAlipayOauthIdentity("alipay-open-id", null)));
Assertions.assertTrue(exception.getMessage().contains("分佣关系尚未绑定"));
verify(alipayClient, never()).execute(any(AlipayTradeCreateRequest.class));
verify(thirdPayOrderService, never()).save(any(ThirdPayOrderEntity.class));
}
/**
* 验证平台下单成功但本地子订单保存失败时立即发起撤销,避免形成孤立可支付订单。
*
@@ -20,6 +20,7 @@ import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateWechatPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayPollingCoordinator;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
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;
@@ -80,6 +81,10 @@ class ThirdAggregateOrderPayServiceImplTest {
@Mock
private ThirdPayOrderService thirdPayOrderService;
/** 商户默认平台分佣关系服务 Mock,用于支付平台选定后的前置校验。 */
@Mock
private ThirdMchFzRelationService mchFzRelationService;
/**
* 钱包商户远程服务 Mock。
*/
@@ -138,7 +143,7 @@ class ThirdAggregateOrderPayServiceImplTest {
void setUp() {
AppPlusPayConfig appPlusPayConfig = completeApplicationConfig();
service = new ThirdAggregateOrderPayServiceImpl(aggregatePaySessionService, aggregateAliPayService,
aggregateWechatPayService, thirdPayOrderService, remoteMchService,
aggregateWechatPayService, thirdPayOrderService, mchFzRelationService, remoteMchService,
remoteTenantService, appPlusPayConfig, stringRedisTemplate, stringEncryptor, pollingCoordinator);
lenient().when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
@@ -360,6 +365,7 @@ class ThirdAggregateOrderPayServiceImplTest {
private MchManageVo completeMerchant() {
MchPaySettingEntity.AliPaySetting aliDetail = new MchPaySettingEntity.AliPaySetting();
aliDetail.setAppAuthToken("merchant-auth-token");
aliDetail.setSubMchid("smid-1001");
MchPaySettingVo ali = new MchPaySettingVo();
ali.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType());
ali.setEnable(YesNoEnum.YES.getCode());
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more