diff --git a/db/20260822_add_expired_reserve_refund.sql b/db/20260822_add_expired_reserve_refund.sql new file mode 100644 index 00000000..728aa179 --- /dev/null +++ b/db/20260822_add_expired_reserve_refund.sql @@ -0,0 +1,49 @@ +-- 每日菜谱预约订单过期自动退款扩展。 +-- 只增加新快照和任务字段,不回填历史订单;执行前应确认目标字段/索引不存在。 + +ALTER TABLE `pjcloudx_wallet`.`mch_auto_refund_config` + ADD COLUMN `expired_reserve_refund_enabled` char(1) NOT NULL DEFAULT '0' + COMMENT '过期预约自动退款开关,0关闭,1开启' AFTER `pay_time_limit_hours`, + ADD COLUMN `expired_reserve_refund_delay_hours` int(11) NOT NULL DEFAULT 2 + COMMENT '餐段结束后的过期退款延迟小时数,允许1至168' AFTER `expired_reserve_refund_enabled`, + ADD COLUMN `expired_reserve_refund_restore_inventory` char(1) NOT NULL DEFAULT '1' + COMMENT '过期预约退款是否恢复库存,0不恢复,1恢复' AFTER `expired_reserve_refund_delay_hours`; + +ALTER TABLE `pjcloudx_hardware`.`hard_order` + ADD COLUMN `expired_refund_config_id` bigint(20) DEFAULT NULL + COMMENT '过期预约退款配置ID快照,仅新支付成功订单写入' AFTER `auto_refund_deadline`, + ADD COLUMN `expired_refund_usage_end_time` datetime DEFAULT NULL + COMMENT '支付成功时快照的实际餐段结束时间,无法解析时为空' AFTER `expired_refund_config_id`, + ADD COLUMN `expired_refund_deadline` datetime DEFAULT NULL + COMMENT '实际餐段结束时间加延迟后的过期退款触发时间' AFTER `expired_refund_usage_end_time`, + ADD COLUMN `expired_refund_restore_inventory` char(1) DEFAULT NULL + COMMENT '过期退款退库存快照,0不恢复,1恢复' AFTER `expired_refund_deadline`, + ADD KEY `idx_hard_order_expired_refund_scan` + (`tenant_id`, `expired_refund_deadline`, `order_status`, `del_flag`); + +ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` + MODIFY COLUMN `after_sale_id` bigint(20) DEFAULT NULL COMMENT '售后单ID,过期预约任务首次执行前为空', + MODIFY COLUMN `rule_type` varchar(32) DEFAULT NULL COMMENT '普通售后任务规则快照,过期预约任务为空', + MODIFY COLUMN `eligibility_deadline` datetime DEFAULT NULL COMMENT '普通售后免审资格截止时间,过期预约任务为空', + ADD COLUMN `task_type` char(10) NOT NULL DEFAULT '10' + COMMENT '任务场景,10用户免审售后,20过期预约退款' AFTER `after_sale_id`, + ADD COLUMN `trigger_time` datetime DEFAULT NULL + COMMENT '过期预约任务按订单快照触发时间,旧售后任务为空' AFTER `eligibility_deadline`, + ADD COLUMN `restore_inventory` char(1) DEFAULT NULL + COMMENT '任务退库存快照,0不恢复,1恢复' AFTER `trigger_time`; + +-- MySQL 5.7 不支持函数索引,使用可索引的存储生成列承载场景业务主键: +-- 普通售后取 after_sale_id,允许同一订单存在多个售后任务;过期预约取 order_id,保证一单一任务。 +ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` + ADD COLUMN `scene_biz_id` bigint(20) + GENERATED ALWAYS AS (CASE WHEN `task_type` = '20' THEN `order_id` ELSE `after_sale_id` END) STORED + COMMENT '任务场景业务唯一值,系统生成,禁止业务代码写入' AFTER `restore_inventory`, + DROP INDEX `uk_hard_auto_refund_task_after_sale`, + ADD UNIQUE KEY `uk_hard_auto_refund_task_scene_biz` + (`tenant_id`, `task_type`, `scene_biz_id`, `del_flag`), + ADD KEY `idx_hard_auto_refund_task_scan_type` + (`tenant_id`, `task_type`, `status`, `next_retry_time`, `del_flag`); + +ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_attempt_log` + MODIFY COLUMN `after_sale_id` bigint(20) DEFAULT NULL COMMENT '售后单ID,过期任务创建售后前为空', + MODIFY COLUMN `refund_code` varchar(128) NOT NULL COMMENT '稳定钱包退款业务号,过期任务按订单生成'; diff --git a/db/20260823_add_hard_order_evidence_image.sql b/db/20260823_add_hard_order_evidence_image.sql new file mode 100644 index 00000000..d0561bd3 --- /dev/null +++ b/db/20260823_add_hard_order_evidence_image.sql @@ -0,0 +1,44 @@ +-- 订单识别证据图片从表。 +-- 本表独立于 hard_order,退款、撤销和订单逻辑删除均不自动清理证据文件或证据记录。 +CREATE TABLE `pjcloudx_hardware`.`hard_order_evidence_image` +( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '证据记录自增主键', + `order_id` bigint(20) NOT NULL COMMENT '订单主键,来源hard_order.id', + `android_order_code` varchar(128) NOT NULL COMMENT '终端本地稳定订单号快照', + `evidence_type` varchar(32) NOT NULL COMMENT '证据类型,FACE_RECOGNITION或MENU_RECOGNITION', + `evidence_role` varchar(32) NOT NULL COMMENT '图片角色,FACE_REFERENCE、FACE_LIVE或MENU_LIVE', + `sequence_no` int(11) NOT NULL DEFAULT 0 COMMENT '同类型同角色图片序号,从0开始', + `user_id` bigint(20) DEFAULT NULL COMMENT '识别用户主键快照', + `face_sample_id` varchar(64) DEFAULT NULL COMMENT '虹软人脸样本编号快照', + `reference_image_url` varchar(512) DEFAULT NULL COMMENT '识别时后台参考人脸来源地址快照,不作为展示地址', + `similarity` decimal(8, 6) DEFAULT NULL COMMENT '识别相似度快照,取值0至1', + `similarity_threshold` decimal(8, 6) DEFAULT NULL COMMENT '终端生效的相似度阈值快照,取值0至1', + `hard_id` bigint(20) NOT NULL COMMENT '产生证据的设备主键', + `captured_at` datetime DEFAULT NULL COMMENT '终端实际采集时间', + `sys_file_id` bigint(20) DEFAULT NULL COMMENT '文件中心sys_file记录主键', + `storage_file_name` varchar(255) DEFAULT NULL COMMENT '文件中心对象名', + `image_url` varchar(512) DEFAULT NULL COMMENT '订单证据图片访问地址', + `image_hash` char(64) DEFAULT NULL COMMENT '实际文件SHA-256哈希', + `image_size` bigint(20) DEFAULT NULL COMMENT '实际文件大小,单位字节', + `image_width` int(11) DEFAULT NULL COMMENT '实际图片宽度,单位像素', + `image_height` int(11) DEFAULT NULL COMMENT '实际图片高度,单位像素', + `status` varchar(32) NOT NULL COMMENT '证据状态,PENDING、AVAILABLE或UNAVAILABLE', + `failure_reason` varchar(500) DEFAULT NULL COMMENT '永久缺失或复制失败摘要,不保存终端完整路径', + `app_version` varchar(64) DEFAULT NULL COMMENT '产生证据的终端应用版本', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '修改人', + `update_time` datetime DEFAULT NULL COMMENT '修改时间', + `del_flag` char(1) DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除', + `tenant_id` bigint(20) NOT NULL COMMENT '租户主键', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_hard_order_evidence_position` + (`tenant_id`, `order_id`, `evidence_type`, `evidence_role`, `sequence_no`, `del_flag`), + KEY `idx_hard_order_evidence_android_order` + (`tenant_id`, `hard_id`, `android_order_code`, `del_flag`), + KEY `idx_hard_order_evidence_status` + (`tenant_id`, `status`, `update_time`, `del_flag`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci + COMMENT = '硬件订单识别证据图片'; diff --git a/db/20260823_add_hard_order_verification_time.sql b/db/20260823_add_hard_order_verification_time.sql new file mode 100644 index 00000000..344c3c96 --- /dev/null +++ b/db/20260823_add_hard_order_verification_time.sql @@ -0,0 +1,16 @@ +-- 硬件订单新增独立核销时间。 +-- 历史二维码核销订单只能使用最后更新时间近似回填;外卖等非核销完成状态不参与回填。 +-- 新核销订单由业务事务写入准确时间,后续订单更新不再改变该值。 + +ALTER TABLE `pjcloudx_hardware`.`hard_order` + ADD COLUMN `verification_time` datetime DEFAULT NULL + COMMENT '订单实际核销完成时间,后续订单更新不得覆盖' AFTER `refund_status`, + ADD KEY `idx_hard_order_verification_time` + (`tenant_id`, `verification_time`, `del_flag`); + +UPDATE `pjcloudx_hardware`.`hard_order` +SET `verification_time` = `update_time` +WHERE ((`order_status` = '20' AND `order_type` IN ('1', '2', '3', '4')) + OR `pay_type` IN ('5', '6', '50')) + AND `verification_time` IS NULL + AND `update_time` IS NOT NULL; diff --git a/db/20260824_add_third_payment_task.sql b/db/20260824_add_third_payment_task.sql new file mode 100644 index 00000000..31468234 --- /dev/null +++ b/db/20260824_add_third_payment_task.sql @@ -0,0 +1,153 @@ +-- thirdparty 支付任务事实表与执行尝试表。 +-- Quartz 是唯一周期触发入口;任务表只保存可恢复事实、租约和幂等状态。 +CREATE TABLE IF NOT EXISTS `third_payment_task` +( + `id` bigint NOT NULL COMMENT '任务主键', + `tenant_id` bigint NOT NULL COMMENT '租户ID', + `task_type` varchar(48) NOT NULL COMMENT '任务类型编码', + `task_key` varchar(192) NOT NULL COMMENT '租户内稳定幂等任务键', + `pay_order_id` bigint DEFAULT NULL COMMENT 'third_pay_order主键', + `fz_order_id` bigint DEFAULT NULL COMMENT 'third_fz_order主键', + `aggregate_session_id` bigint DEFAULT NULL COMMENT 'third_aggregate_pay_session主键', + `pay_no` varchar(128) DEFAULT NULL COMMENT '平台支付单号', + `refund_no` varchar(128) DEFAULT NULL COMMENT '平台退款单号', + `fz_no` varchar(128) DEFAULT NULL COMMENT '平台分账单号', + `aggregate_no` varchar(128) DEFAULT NULL COMMENT '聚合会话单号', + `status` varchar(16) NOT NULL COMMENT '任务状态:READY/PROCESSING/RETRY/SUCCESS/FAILED/ALERT/CANCELLED', + `next_run_time` datetime NOT NULL COMMENT '下一次可执行时间', + `priority` int NOT NULL DEFAULT 0 COMMENT '任务优先级,数值越大越优先', + `lease_owner` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识', + `lease_token` varchar(64) DEFAULT NULL COMMENT '本次执行租约令牌', + `lease_expire_time` datetime DEFAULT NULL COMMENT '租约失效时间', + `attempt_count` int NOT NULL DEFAULT 0 COMMENT '累计执行次数', + `last_external_status` varchar(64) DEFAULT NULL COMMENT '最近一次渠道状态', + `last_error_code` varchar(128) DEFAULT NULL COMMENT '最近一次错误码', + `last_error_message` varchar(1024) DEFAULT NULL COMMENT '最近一次错误摘要', + `finished_time` datetime DEFAULT NULL COMMENT '任务终态时间', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '修改人', + `update_time` datetime NOT NULL COMMENT '更新时间', + `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_third_payment_task_tenant_key` (`tenant_id`, `task_key`), + KEY `idx_third_payment_task_due` (`task_type`, `status`, `next_run_time`, `priority`, `id`), + KEY `idx_third_payment_task_lease` (`task_type`, `status`, `lease_expire_time`), + KEY `idx_third_payment_task_pay_no` (`tenant_id`, `pay_no`), + KEY `idx_third_payment_task_fz_no` (`tenant_id`, `fz_no`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务事实'; + +CREATE TABLE IF NOT EXISTS `third_payment_task_attempt` +( + `id` bigint NOT NULL COMMENT '尝试记录主键', + `task_id` bigint NOT NULL COMMENT '任务主键', + `tenant_id` bigint NOT NULL COMMENT '租户ID', + `attempt_no` int NOT NULL COMMENT '任务内尝试序号', + `trigger_source` varchar(32) NOT NULL COMMENT '触发来源:QUARTZ/CALLBACK/SYNC/MANUAL', + `worker_id` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识', + `lease_token` varchar(64) DEFAULT NULL COMMENT '执行租约令牌', + `request_snapshot` text DEFAULT NULL COMMENT '脱敏请求快照', + `response_snapshot` text DEFAULT NULL COMMENT '脱敏响应快照', + `external_status` varchar(64) DEFAULT NULL COMMENT '渠道状态', + `error_code` varchar(128) DEFAULT NULL COMMENT '错误码', + `error_message` varchar(1024) DEFAULT NULL COMMENT '错误摘要', + `started_time` datetime NOT NULL COMMENT '开始时间', + `finished_time` datetime DEFAULT NULL COMMENT '结束时间', + PRIMARY KEY (`id`), + KEY `idx_third_payment_attempt_task` (`task_id`, `attempt_no`), + KEY `idx_third_payment_attempt_tenant_time` (`tenant_id`, `started_time`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务执行尝试'; + +-- Quartz 会按业务事实持续物化任务。百万级订单量下,如果业务表只有主键,每个固定触发器都会退化为全表扫描。 +-- 以下索引只覆盖实际扫描谓词和租户内业务号关联;使用 information_schema 判断保证迁移可以重复执行。 +SET @index_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_task_scan'), + 'SELECT ''idx_third_pay_order_task_scan already exists'' AS message', + 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_task_scan` (`status`, `del_flag`, `pay_platform_type`, `update_time`, `id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +SET @index_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_fz_scan'), + 'SELECT ''idx_third_pay_order_fz_scan already exists'' AS message', + 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_fz_scan` (`status`, `enable_fz`, `del_flag`, `pay_time`, `id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +SET @index_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_callback_scan'), + 'SELECT ''idx_third_pay_order_callback_scan already exists'' AS message', + 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_callback_scan` (`status`, `is_biz_callback`, `del_flag`, `update_time`, `id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +SET @index_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_close_scan'), + 'SELECT ''idx_third_pay_order_close_scan already exists'' AS message', + 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_close_scan` (`status`, `biz_type`, `del_flag`, `create_time`, `id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +SET @index_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_tenant_pay_no'), + 'SELECT ''idx_third_pay_order_tenant_pay_no already exists'' AS message', + 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +SET @index_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'third_fz_order' AND index_name = 'idx_third_fz_order_task_scan'), + 'SELECT ''idx_third_fz_order_task_scan already exists'' AS message', + 'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_task_scan` (`status`, `del_flag`, `update_time`, `id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +SET @index_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'third_fz_order' AND index_name = 'idx_third_fz_order_tenant_pay_no'), + 'SELECT ''idx_third_fz_order_tenant_pay_no already exists'' AS message', + 'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +SET @index_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'third_aggregate_pay_session' AND index_name = 'idx_third_aggregate_task_scan'), + 'SELECT ''idx_third_aggregate_task_scan already exists'' AS message', + 'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_task_scan` (`status`, `del_flag`, `update_time`, `id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +SET @index_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'third_aggregate_pay_session' AND index_name = 'idx_third_aggregate_close_scan'), + 'SELECT ''idx_third_aggregate_close_scan already exists'' AS message', + 'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_close_scan` (`status`, `del_flag`, `create_time`, `id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +-- 生产执行建议:低峰期使用在线 DDL 能力建索引,完成后 ANALYZE TABLE 并用真实状态分布执行 EXPLAIN ANALYZE。 diff --git a/db/20260824_optimize_sys_user_page_indexes.sql b/db/20260824_optimize_sys_user_page_indexes.sql new file mode 100644 index 00000000..9718c431 --- /dev/null +++ b/db/20260824_optimize_sys_user_page_indexes.sql @@ -0,0 +1,111 @@ +-- 系统用户分页查询索引优化。 +-- +-- 适用接口:POST /admin/user/page、POST /admin/user/select。 +-- 执行影响:仅新增普通索引,不修改字段、约束和历史数据;建索引期间会消耗 IO,建议在业务低峰期执行。 +-- 执行前建议:先备份数据库,并使用文末查询核对生产库已有索引及 EXPLAIN 计划。 + +-- 用户分页的默认排序索引:覆盖租户、逻辑删除和 create_time + user_id 稳定倒序分页。 +SET @index_ddl = IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'sys_user' + AND index_name = 'idx_sys_user_tenant_del_created' + ), + 'SELECT ''idx_sys_user_tenant_del_created already exists'' AS message', + 'ALTER TABLE `sys_user` ADD INDEX `idx_sys_user_tenant_del_created` (`tenant_id`, `del_flag`, `create_time`, `user_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 = 'sys_user' + AND index_name = 'idx_sys_user_tenant_del_dept_status_created' + ), + 'SELECT ''idx_sys_user_tenant_del_dept_status_created already exists'' AS message', + 'ALTER TABLE `sys_user` ADD INDEX `idx_sys_user_tenant_del_dept_status_created` (`tenant_id`, `del_flag`, `dept_id`, `process_status`, `create_time`, `user_id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +-- 角色反向筛选索引:现有主键通常为 user_id + role_id,本索引用于从角色快速定位用户。 +SET @index_ddl = IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'sys_user_role' + AND index_name = 'idx_sys_user_role_role_user' + ), + 'SELECT ''idx_sys_user_role_role_user already exists'' AS message', + 'ALTER TABLE `sys_user_role` ADD INDEX `idx_sys_user_role_role_user` (`role_id`, `user_id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +-- 当前页人脸聚合及有无人脸 EXISTS 筛选索引,避免按用户查询时回表扫描租户全部人脸。 +SET @index_ddl = IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'sys_user_face' + AND index_name = 'idx_sys_user_face_tenant_user_del' + ), + 'SELECT ''idx_sys_user_face_tenant_user_del already exists'' AS message', + 'ALTER TABLE `sys_user_face` ADD INDEX `idx_sys_user_face_tenant_user_del` (`tenant_id`, `user_id`, `del_flag`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +-- 身份类型反向筛选索引:支持 identity_type 条件先定位用户,用户 ID 同时用于半连接。 +SET @index_ddl = IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'sys_user_identity' + AND index_name = 'idx_sys_user_identity_type_user' + ), + 'SELECT ''idx_sys_user_identity_type_user already exists'' AS message', + 'ALTER TABLE `sys_user_identity` ADD INDEX `idx_sys_user_identity_type_user` (`identity_type`, `user_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 = 'sys_dept' + AND index_name = 'idx_sys_dept_tenant_del_parent' + ), + 'SELECT ''idx_sys_dept_tenant_del_parent already exists'' AS message', + 'ALTER TABLE `sys_dept` ADD INDEX `idx_sys_dept_tenant_del_parent` (`tenant_id`, `del_flag`, `parent_id`, `dept_id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +-- 执行后核对新增索引;确认无重复或同前缀冗余索引后,再按生产变更流程决定是否清理旧索引。 +SELECT table_name, index_name, seq_in_index, column_name +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name IN ('sys_user', 'sys_user_role', 'sys_user_face', 'sys_user_identity', 'sys_dept') +ORDER BY table_name, index_name, seq_in_index; + +-- 建议在执行索引后更新统计信息,并用真实租户、部门和状态参数执行 EXPLAIN ANALYZE。 +-- ANALYZE TABLE sys_user, sys_user_role, sys_user_face, sys_user_identity, sys_dept; diff --git a/db/20260825_add_third_mch_fz_relation.sql b/db/20260825_add_third_mch_fz_relation.sql new file mode 100644 index 00000000..25115867 --- /dev/null +++ b/db/20260825_add_third_mch_fz_relation.sql @@ -0,0 +1,79 @@ +USE `pjcloudx_thirdparty`; + +-- 商户默认平台分佣关系。微信关系由 sub_mchid 区分,支付宝关系由调用应用、授权商户和接收账号共同确定。 +-- 表中只保存渠道标识和脱敏调用快照,不保存私钥、公钥或完整 app_auth_token。 +CREATE TABLE IF NOT EXISTS `third_mch_fz_relation` +( + `id` bigint NOT NULL COMMENT '关系主键,应用侧雪花算法生成', + `tenant_id` bigint NOT NULL COMMENT '租户ID,与 mch_manage 所属租户一致', + `mch_id` bigint NOT NULL COMMENT 'mch_manage 商户主键', + `pay_setting_id` bigint NOT NULL COMMENT 'mch_pay_setting 支付配置主键', + `platform_type` varchar(32) NOT NULL COMMENT '支付平台类型:10微信、20支付宝', + `relation_scene` varchar(64) NOT NULL COMMENT '本地关系场景,默认平台分佣为 DEFAULT_PLATFORM_COMMISSION', + `channel_app_id` varchar(64) NOT NULL COMMENT '微信服务商AppID或支付宝平台AppID快照', + `channel_sub_mch_id` varchar(64) NOT NULL COMMENT '微信子商户号或支付宝直付通smid/ip_role_id', + `account_key` varchar(64) DEFAULT NULL COMMENT '支付宝命名平台账号键;默认账号及微信为空', + `receiver_type` varchar(32) NOT NULL COMMENT '渠道接收方类型,如MERCHANT_ID、userId、loginName', + `receiver_account` varchar(128) NOT NULL COMMENT '平台分佣接收账号;管理端展示时必须脱敏', + `receiver_name` varchar(128) DEFAULT NULL COMMENT '平台分佣接收方名称快照', + `channel_relation_type` varchar(64) DEFAULT NULL COMMENT '渠道关系类型;微信默认SERVICE_PROVIDER,支付宝为空', + `out_request_no` varchar(64) NOT NULL COMMENT '支付宝绑定稳定幂等请求号;微信作为本地稳定审计号', + `status` varchar(32) NOT NULL COMMENT 'PENDING_BIND、BINDING、BOUND、BIND_FAILED或SUPERSEDED', + `is_default` char(1) NOT NULL DEFAULT '1' COMMENT '是否为当前支付配置的默认平台分佣关系:0否、1是', + `bind_req` text DEFAULT NULL COMMENT '脱敏后的关系绑定请求快照', + `bind_res` text DEFAULT NULL COMMENT '渠道关系绑定或查询响应快照', + `bind_error` varchar(1000) DEFAULT NULL COMMENT '最近一次绑定错误摘要,不含凭据', + `last_external_status` varchar(128) DEFAULT NULL COMMENT '最近一次渠道结果码或外部状态', + `last_query_time` datetime DEFAULT NULL COMMENT '最近一次关系绑定或查询时间', + `bound_time` datetime DEFAULT NULL COMMENT '渠道明确确认关系已绑定的时间', + `create_by` varchar(32) DEFAULT NULL COMMENT '创建人', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_by` varchar(32) DEFAULT NULL COMMENT '更新人', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记:0正常、1删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_third_mch_fz_relation_identity` + (`tenant_id`, `pay_setting_id`, `platform_type`, `channel_app_id`, `channel_sub_mch_id`, + `receiver_type`, `receiver_account`, `relation_scene`), + KEY `idx_third_mch_fz_relation_default` + (`tenant_id`, `mch_id`, `platform_type`, `is_default`, `status`, `del_flag`), + KEY `idx_third_mch_fz_relation_task` + (`status`, `is_default`, `update_time`, `id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci + COMMENT ='商户第三方支付分账关系'; + +-- 支付任务通过结构化主键定位关系事实,禁止从 task_key 字符串反解析业务主键。 +SET @relation_task_column_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'third_payment_task' + AND COLUMN_NAME = 'fz_relation_id' +); +SET @relation_task_column_sql = IF( + @relation_task_column_exists = 0, + 'ALTER TABLE `third_payment_task` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''商户分账关系主键'' AFTER `fz_order_id`, ADD INDEX `idx_third_payment_task_relation` (`tenant_id`, `fz_relation_id`)', + 'SELECT 1' +); +PREPARE relation_task_column_stmt FROM @relation_task_column_sql; +EXECUTE relation_task_column_stmt; +DEALLOCATE PREPARE relation_task_column_stmt; + +-- 支付订单固定引用创建时使用的已绑定关系,后续分账不跟随平台接收账号配置漂移。 +SET @pay_relation_column_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'third_pay_order' + AND COLUMN_NAME = 'fz_relation_id' +); +SET @pay_relation_column_sql = IF( + @pay_relation_column_exists = 0, + 'ALTER TABLE `third_pay_order` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''支付创建时绑定的平台分佣关系主键'' AFTER `mch_pay_setting_json`, ADD INDEX `idx_third_pay_order_fz_relation` (`tenant_id`, `fz_relation_id`)', + 'SELECT 1' +); +PREPARE pay_relation_column_stmt FROM @pay_relation_column_sql; +EXECUTE pay_relation_column_stmt; +DEALLOCATE PREPARE pay_relation_column_stmt; diff --git a/db/20260825_add_wallet_transaction_remark.sql b/db/20260825_add_wallet_transaction_remark.sql new file mode 100644 index 00000000..ee5f5b73 --- /dev/null +++ b/db/20260825_add_wallet_transaction_remark.sql @@ -0,0 +1,3 @@ +-- 为每笔钱包流水增加可选业务备注;历史流水保持为空,前后端统一限制为 256 个字符。 +ALTER TABLE wallet_transaction + ADD COLUMN remark varchar(256) DEFAULT NULL COMMENT '流水备注,最多256个字符' AFTER tags; diff --git a/db/800pjcloudx_hardware.sql b/db/800pjcloudx_hardware.sql index 0c2a37f1..173d7d84 100644 --- a/db/800pjcloudx_hardware.sql +++ b/db/800pjcloudx_hardware.sql @@ -301,6 +301,50 @@ CREATE TABLE hard_order PRIMARY KEY (id) ) ENGINE = InnoDB COMMENT ='订单表'; +-- ---------------------------- +-- Table structure for hard_order_evidence_image +-- ---------------------------- +DROP TABLE IF EXISTS `hard_order_evidence_image`; +CREATE TABLE hard_order_evidence_image +( + id bigint NOT NULL AUTO_INCREMENT COMMENT '证据记录自增主键', + order_id bigint NOT NULL COMMENT '订单主键,来源hard_order.id', + android_order_code varchar(128) NOT NULL COMMENT '终端本地稳定订单号快照', + evidence_type varchar(32) NOT NULL COMMENT '证据类型,FACE_RECOGNITION或MENU_RECOGNITION', + evidence_role varchar(32) NOT NULL COMMENT '图片角色,FACE_REFERENCE、FACE_LIVE或MENU_LIVE', + sequence_no int NOT NULL DEFAULT 0 COMMENT '同类型同角色图片序号,从0开始', + user_id bigint DEFAULT NULL COMMENT '识别用户主键快照', + face_sample_id varchar(64) DEFAULT NULL COMMENT '虹软人脸样本编号快照', + reference_image_url varchar(512) DEFAULT NULL COMMENT '识别时后台参考人脸来源地址快照,不作为展示地址', + similarity decimal(8, 6) DEFAULT NULL COMMENT '识别相似度快照,取值0至1', + similarity_threshold decimal(8, 6) DEFAULT NULL COMMENT '终端生效的相似度阈值快照,取值0至1', + hard_id bigint NOT NULL COMMENT '产生证据的设备主键', + captured_at datetime DEFAULT NULL COMMENT '终端实际采集时间', + sys_file_id bigint DEFAULT NULL COMMENT '文件中心sys_file记录主键', + storage_file_name varchar(255) DEFAULT NULL COMMENT '文件中心对象名', + image_url varchar(512) DEFAULT NULL COMMENT '订单证据图片访问地址', + image_hash char(64) DEFAULT NULL COMMENT '实际文件SHA-256哈希', + image_size bigint DEFAULT NULL COMMENT '实际文件大小,单位字节', + image_width int DEFAULT NULL COMMENT '实际图片宽度,单位像素', + image_height int DEFAULT NULL COMMENT '实际图片高度,单位像素', + status varchar(32) NOT NULL COMMENT '证据状态,PENDING、AVAILABLE或UNAVAILABLE', + failure_reason varchar(500) DEFAULT NULL COMMENT '永久缺失或复制失败摘要,不保存终端完整路径', + app_version varchar(64) DEFAULT NULL COMMENT '产生证据的终端应用版本', + create_by varchar(64) DEFAULT NULL COMMENT '创建人', + create_time datetime DEFAULT NULL COMMENT '创建时间', + update_by varchar(64) DEFAULT NULL COMMENT '修改人', + update_time datetime DEFAULT NULL COMMENT '修改时间', + del_flag char(1) DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除', + tenant_id bigint NOT NULL COMMENT '租户主键', + PRIMARY KEY (id), + UNIQUE KEY uk_hard_order_evidence_position + (tenant_id, order_id, evidence_type, evidence_role, sequence_no, del_flag), + KEY idx_hard_order_evidence_android_order + (tenant_id, hard_id, android_order_code, del_flag), + KEY idx_hard_order_evidence_status + (tenant_id, status, update_time, del_flag) +) ENGINE = InnoDB COMMENT ='硬件订单识别证据图片'; + -- ---------------------------- -- Table structure for hard_order_address @@ -724,4 +768,3 @@ ALTER TABLE hard_xfj_log ADD INDEX idx_start_time (start_time); ALTER TABLE hard_xfj_log ADD INDEX idx_end_time (end_time); - diff --git a/db/800pjcloudx_wallet.sql b/db/800pjcloudx_wallet.sql index c4583ce2..462e9448 100644 --- a/db/800pjcloudx_wallet.sql +++ b/db/800pjcloudx_wallet.sql @@ -456,6 +456,7 @@ CREATE TABLE wallet_transaction price int NOT NULL COMMENT '交易金额, 单位分', balance int NOT NULL COMMENT '余额, 单位分', tags varchar(512) DEFAULT NULL COMMENT '财务统计标签,多个wallet_transaction_tag字典值使用英文逗号分隔', + remark varchar(256) DEFAULT NULL COMMENT '流水备注,最多256个字符', create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人', create_time datetime DEFAULT NULL COMMENT '创建时间', update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人', diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java index 7e8738ce..be6fef78 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java @@ -26,6 +26,7 @@ import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity; import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO; import com.bsd.pjcloud.common.core.constant.ServiceNameConstants; import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.feign.annotation.NoToken; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -67,6 +68,16 @@ public interface RemoteCanteenSpuService { @PostMapping("/canteenSpu/ad/updateSkuStock") R updateSkuStock(@RequestBody CanteenSpuUpdateStockReqDTO updateStockReqDTO); + /** + * 按稳定业务号原子应用或回滚每日菜谱基础 SPU 库存变更。 + * + * @param updateStockReqDTO 商品增量、操作类型、业务号和操作前库存快照 + * @return 操作成功时返回成功响应,重复请求按幂等成功处理 + */ + @PostMapping("/canteenSpu/inner/updateSkuStockIdempotent") + @NoToken + R updateSkuStockIdempotent(@RequestBody CanteenSpuUpdateStockReqDTO updateStockReqDTO); + /** * 根据ids查询菜品 @@ -75,6 +86,16 @@ public interface RemoteCanteenSpuService { @PostMapping("/canteenSpu/ad/getSpuListByIds") R> getSpuListByIds(@RequestBody List ids); + /** + * 供 Hardware 定时任务等无用户上下文的服务间调用批量查询菜品。 + * + * @param ids 菜品 SPU 主键列表 + * @return 当前租户下命中的菜品列表 + */ + @PostMapping("/canteenSpu/inner/getSpuListByIds") + @NoToken + R> getInnerSpuListByIds(@RequestBody List ids); + /** * 按套餐 SPU ID 批量查询套餐组成,用于跨服务补充只读展示数据。 * diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java index ca682974..c09b7c08 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java @@ -25,9 +25,10 @@ public class CanteenSpuUpdateStockReqDTO { private String bizId; /** - * DEDUCT 扣减,ROLLBACK 恢复;仅在 bizId 非空时参与幂等控制。 + * 库存幂等操作类型。DEDUCT 保留给在线菜品旧协议,APPLY 按 incrCount 正负值应用基础菜品库存变更, + * ROLLBACK 使用同一业务号反向撤销 APPLY/DEDUCT;仅在 bizId 非空时参与幂等控制。 */ - @Pattern(regexp = "DEDUCT|ROLLBACK", message = "库存操作类型错误") + @Pattern(regexp = "DEDUCT|APPLY|ROLLBACK", message = "库存操作类型错误") private String operation; /** diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java index 27a6466e..45bfeb97 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java @@ -30,6 +30,7 @@ import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity; import com.bsd.pjcloud.canteen.service.CanteenSpuService; import com.bsd.pjcloud.common.security.util.SecurityUtils; +import com.bsd.pjcloud.common.security.annotation.Inner; import org.springframework.security.access.prepost.PreAuthorize; import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -68,6 +69,19 @@ public class CanteenSpuController { private final CanteenMerchantScopeHelper merchantScopeHelper; + /** + * 每日菜谱订单按业务号应用或回滚基础菜品库存,调用方必须携带操作前库存快照。 + * + * @param updateStockReqDTO 库存业务号、操作类型及商品增量 + * @return 幂等库存操作结果 + */ + @Inner + @PostMapping("/inner/updateSkuStockIdempotent") + @Transactional(rollbackFor = Exception.class) + public R updateSkuStockIdempotent(@RequestBody CanteenSpuUpdateStockReqDTO updateStockReqDTO) { + return canteenSpuService.updateSkuStockIdempotent(updateStockReqDTO); + } + /** * 分页查询 * @@ -369,7 +383,30 @@ public class CanteenSpuController { @Operation(summary = "通过ids查询" , description = "通过ids查询" ) @PostMapping("/ad/getSpuListByIds" ) public R> getByIds(@RequestBody List ids) { - List list = canteenSpuService.list(new LambdaQueryWrapper().in(CanteenSpuEntity::getId, ids)); + return querySpuByIds(ids); + } + + /** + * 为无用户令牌的 Hardware 服务间调用提供菜品批量查询,保持原管理端接口的鉴权行为不变。 + * + * @param ids 菜品 SPU 主键列表 + * @return 当前租户下命中的菜品列表 + */ + @Inner + @PostMapping("/inner/getSpuListByIds") + public R> getInnerByIds(@RequestBody List ids) { + return querySpuByIds(ids); + } + + /** + * 统一执行菜品 SPU 批量查询,避免管理端和内部端点产生不同的数据筛选口径。 + * + * @param ids 菜品 SPU 主键列表 + * @return 当前租户下命中的菜品列表 + */ + private R> querySpuByIds(List ids) { + List list = canteenSpuService.list(new LambdaQueryWrapper() + .in(CanteenSpuEntity::getId, ids)); return R.ok(list); } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenSpuMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenSpuMapper.java index 227f5f9a..706a6b64 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenSpuMapper.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenSpuMapper.java @@ -13,11 +13,11 @@ public interface CanteenSpuMapper extends PjcloudBaseMapper { * 更新商品 SPU 库存 * * @param id 商品 SPU 编号 - * @param incrCount 增加的库存数量 + * @param incrCount 库存增量,正数恢复库存、负数扣减库存 */ default int updateStock(Long id, Integer incrCount) { // 拼接 SQL - if (incrCount == 0) { + if (id == null || incrCount == null || incrCount == 0 || incrCount == Integer.MIN_VALUE) { return 0; } String sql; @@ -29,7 +29,36 @@ public interface CanteenSpuMapper extends PjcloudBaseMapper { // 执行更新 LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper() .setSql(sql) - .eq(CanteenSpuEntity::getId, id); + .eq(CanteenSpuEntity::getId, id) + // 扣减条件必须落到同一条 UPDATE,避免并发请求都通过前置查询后把库存扣成负数。 + .ge(incrCount < 0, CanteenSpuEntity::getStock, Math.abs(incrCount)); return update(null, updateWrapper); } -} \ No newline at end of file + + /** + * 仅在数据库当前库存仍等于业务快照时更新库存和销量,避免不同业务号并发操作同一 SPU 时丢失更新。 + * + * @param id 商品 SPU 编号 + * @param incrCount 库存增量,负数扣减、正数恢复 + * @param expectedStock 本次业务读取到的操作前库存 + * @return 影响行数;返回 0 表示库存已变化或扣减后会小于 0 + */ + default int updateStockIfMatch(Long id, Integer incrCount, Integer expectedStock) { + if (id == null || incrCount == null || incrCount == 0 || incrCount == Integer.MIN_VALUE + || expectedStock == null || expectedStock < 0) { + return 0; + } + String sql; + if (incrCount > 0) { + sql = " stock = stock + " + incrCount + ", sales_count = sales_count - " + incrCount; + } else { + sql = " stock = stock - " + Math.abs(incrCount) + ", sales_count = sales_count + " + Math.abs(incrCount); + } + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper() + .setSql(sql) + .eq(CanteenSpuEntity::getId, id) + .eq(CanteenSpuEntity::getStock, expectedStock) + .ge(incrCount < 0, CanteenSpuEntity::getStock, Math.abs(incrCount)); + return update(null, updateWrapper); + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenSpuService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenSpuService.java index 6164cd82..de78463e 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenSpuService.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenSpuService.java @@ -32,6 +32,14 @@ public interface CanteenSpuService extends IService { */ R updateSkuStock(CanteenSpuUpdateStockReqDTO updateStockReqDTO); + /** + * 按业务号原子应用或回滚基础菜品库存,供每日菜谱下单和退款跨服务调用。 + * + * @param updateStockReqDTO 商品库存增量、操作前快照、业务号和操作类型 + * @return 成功、幂等命中或库存校验失败的统一响应 + */ + R updateSkuStockIdempotent(CanteenSpuUpdateStockReqDTO updateStockReqDTO); + /** * 根据菜品分类id获取菜品信息 @@ -106,4 +114,4 @@ public interface CanteenSpuService extends IService { * 批量加载套餐组成(App/每日菜谱展示) */ Map> loadComboItemVos(List comboSpuIds); -} \ No newline at end of file +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java index 20913428..008f3853 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java @@ -2,6 +2,7 @@ package com.bsd.pjcloud.canteen.service.impl; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; +import com.baomidou.lock.annotation.Lock4j; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; @@ -26,10 +27,14 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; import lombok.AllArgsConstructor; import org.apache.commons.lang3.StringUtils; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.util.CollectionUtils; +import java.time.Duration; import java.time.LocalDateTime; import java.util.*; import java.util.stream.Collectors; @@ -44,6 +49,17 @@ import java.util.stream.Collectors; @AllArgsConstructor public class CanteenSpuServiceImpl extends ServiceImpl implements CanteenSpuService { + /** 每日菜谱基础 SPU 库存幂等状态键前缀,租户隔离由 Redis 基础设施统一处理。 */ + private static final String ORDER_STOCK_BIZ_KEY_PREFIX = "canteen:spu:order-stock:"; + + /** 库存幂等状态最长保留时间,覆盖远程超时重试和延迟请求后自动释放 Redis 空间。 */ + private static final Duration ORDER_STOCK_BIZ_STATE_TTL = Duration.ofDays(7); + + /** 已确认 APPLY 成功且随后完成反向补偿;允许业务任务使用同一幂等号显式重试。 */ + private static final String ORDER_STOCK_BIZ_STATE_COMPENSATED = "COMPENSATED"; + + /** 库存业务状态存储,用于识别跨服务超时后的重复应用和补偿请求。 */ + private final StringRedisTemplate stringRedisTemplate; private final CanteenDailyMenuMapper canteenDailyMenuMapper; private final CanteenCategoryMapper canteenCategoryMapper; @@ -58,29 +74,152 @@ public class CanteenSpuServiceImpl extends ServiceImpl { - // 查询当前库存 + if (updateStockReqDTO == null || CollUtil.isEmpty(updateStockReqDTO.getItems())) { + return R.failed("库存商品不能为空"); + } + // 必须先校验完整批次再更新,后一个商品失败时由本地事务保证前面的商品不会单独提交。 + for (CanteenSpuUpdateStockReqDTO.Item item : updateStockReqDTO.getItems()) { + if (item == null || item.getId() == null || item.getIncrCount() == null + || item.getIncrCount() == 0 || item.getIncrCount() == Integer.MIN_VALUE) { + return R.failed("库存商品参数错误"); + } CanteenSpuEntity spu = baseMapper.selectById(item.getId()); if (spu == null) { - R.failed(spu.getName() + "商品不存在"); + return R.failed("商品不存在"); } - // 检查库存是否足够 - if (item.getIncrCount() < 0 && Math.abs(item.getIncrCount()) > spu.getStock()) { - R.failed(spu.getName() + "库存不足,无法完成操作"); + if (item.getIncrCount() == null || item.getIncrCount() == 0) { + return R.failed(StrUtil.blankToDefault(spu.getName(), "商品") + "库存变化数量无效"); } - int i = baseMapper.updateStock(item.getId(), item.getIncrCount()); -// if (item.getIncrCount() <= 0) { -// if (i == 0) { -// R.failed("商品库存不足"); -// } -// } - }); + if (item.getIncrCount() < 0 && (spu.getStock() == null || Math.abs(item.getIncrCount()) > spu.getStock())) { + return R.failed(StrUtil.blankToDefault(spu.getName(), "商品") + "库存不足,无法完成操作"); + } + } + for (CanteenSpuUpdateStockReqDTO.Item item : updateStockReqDTO.getItems()) { + if (baseMapper.updateStock(item.getId(), item.getIncrCount()) == 0) { + throw new RuntimeException("库存不足,无法完成操作"); + } + } return R.ok(); } + /** + * 对同一业务号串行应用或反向补偿基础 SPU 库存。APPLY 同时支持下单负增量和退款正增量, + * ROLLBACK 必须携带原 APPLY 的增量与操作前快照,避免远程响应超时时重复扣减或重复恢复。 + * + * @param dto 商品增量、操作前库存快照、稳定业务号和操作类型 + * @return 成功、幂等命中或库存状态冲突的统一响应 + */ + @Override + @Lock4j(keys = {"#dto.bizId"}) + @Transactional(rollbackFor = Exception.class) + public R updateSkuStockIdempotent(CanteenSpuUpdateStockReqDTO dto) { + String operation = dto == null ? null : StrUtil.trim(dto.getOperation()); + boolean applyOperation = "APPLY".equals(operation); + boolean rollbackOperation = "ROLLBACK".equals(operation); + if (dto == null || StrUtil.isBlank(dto.getBizId()) || CollUtil.isEmpty(dto.getItems()) + || (!applyOperation && !rollbackOperation) + || dto.getItems().stream().anyMatch(item -> item == null || item.getId() == null + || item.getIncrCount() == null || item.getIncrCount() == 0 || item.getBeforeStock() == null)) { + return R.failed("库存业务参数错误"); + } + if (dto.getItems().stream().map(CanteenSpuUpdateStockReqDTO.Item::getId).distinct().count() != dto.getItems().size()) { + return R.failed("库存商品不能重复"); + } + + String stateKey = ORDER_STOCK_BIZ_KEY_PREFIX + dto.getBizId(); + String state = stringRedisTemplate.opsForValue().get(stateKey); + // ROLLED_BACK 表示补偿先于 APPLY 或无法确认 APPLY 成功,必须阻断延迟请求; + // COMPENSATED 表示已确认 APPLY 后又完成反向补偿,任务重试可以使用新库存快照重新执行。 + if (applyOperation && ("APPLIED".equals(state) || "ROLLED_BACK".equals(state))) { + return R.ok(); + } + if (rollbackOperation && ("ROLLED_BACK".equals(state) + || ORDER_STOCK_BIZ_STATE_COMPENSATED.equals(state))) { + return R.ok(); + } + for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) { + CanteenSpuEntity spu = baseMapper.selectById(item.getId()); + if (spu == null || spu.getStock() == null) { + return R.failed("商品不存在或库存异常"); + } + int expectedStock = item.getBeforeStock(); + if (expectedStock < 0) { + return R.failed("库存快照异常"); + } + int targetStock = Math.addExact(expectedStock, item.getIncrCount()); + if (targetStock < 0) { + return R.failed(StrUtil.blankToDefault(spu.getName(), "商品") + "库存不足,无法完成操作"); + } + if (applyOperation) { + // 不得把“库存恰好等于目标值”当成本业务已经执行;该值可能由另一个业务号并发扣减得到。 + // 幂等成功只能依赖上方读取到的 APPLIED 状态,状态缺失时必须从调用方快照原子更新。 + if (spu.getStock() != expectedStock) { + return R.failed(StrUtil.blankToDefault(spu.getName(), "商品") + "库存已变化,请重新操作"); + } + } else if ("APPLIED".equals(state)) { + int rollbackDelta = -item.getIncrCount(); + if (rollbackDelta < 0 && spu.getStock() < Math.abs(rollbackDelta)) { + return R.failed(StrUtil.blankToDefault(spu.getName(), "商品") + "库存不足,无法自动补偿"); + } + } + } + + for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) { + int expectedStock = item.getBeforeStock(); + int delta = applyOperation ? item.getIncrCount() : -item.getIncrCount(); + if (rollbackOperation && "APPLIED".equals(state)) { + // 补偿不能再要求库存等于本业务 APPLY 后的快照:期间可能已有其他订单合法改变同一 SPU。 + // 同一业务号由锁和 APPLIED 状态保证只反向一次,数据库条件更新只负责阻止库存扣成负数。 + if (baseMapper.updateStock(item.getId(), delta) == 0) { + throw new RuntimeException("库存补偿失败,请重试"); + } + continue; + } + // 状态缺失的 ROLLBACK 说明没有可确认的成功 APPLY,只写入终态阻断延迟请求,不能按库存值猜测并恢复。 + if (applyOperation && baseMapper.updateStockIfMatch(item.getId(), delta, expectedStock) == 0) { + throw new RuntimeException("库存已变化,请重新操作"); + } + } + String targetState = applyOperation ? "APPLIED" + : "APPLIED".equals(state) ? ORDER_STOCK_BIZ_STATE_COMPENSATED : "ROLLED_BACK"; + updateStockBizState(stateKey, state, targetState); + return R.ok(); + } + + /** + * 写入库存幂等状态,并在食堂本地数据库事务回滚时恢复原状态,防止出现“状态成功、库存未提交”。 + * + * @param stateKey 当前租户下的库存业务状态键 + * @param previousState 调用前状态,空值表示此前没有记录 + * @param targetState 本次库存操作完成后的状态 + */ + private void updateStockBizState(String stateKey, String previousState, String targetState) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + throw new RuntimeException("库存事务状态异常,请稍后重试"); + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** + * 数据库事务回滚后同步撤销本次 Redis 状态,后续请求才能根据实际库存重新判断。 + * + * @param status Spring 本地事务完成状态 + */ + @Override + public void afterCompletion(int status) { + if (status != TransactionSynchronization.STATUS_ROLLED_BACK) { + return; + } + if (previousState == null) { + stringRedisTemplate.delete(stateKey); + } else { + stringRedisTemplate.opsForValue().set(stateKey, previousState, ORDER_STOCK_BIZ_STATE_TTL); + } + } + }); + stringRedisTemplate.opsForValue().set(stateKey, targetState, ORDER_STOCK_BIZ_STATE_TTL); + } + @Override public R getCanteenSpuList(String categoryId) { List canteenSpuEntities = baseMapper.selectListByScope(new LambdaQueryWrapper() @@ -481,4 +620,4 @@ public class CanteenSpuServiceImpl extends ServiceImpl valueOperations; + + /** + * 为每个用例建立独立的事务同步上下文,模拟 Hardware 远程调用进入食堂本地事务后的状态写入。 + */ + @BeforeEach + void setUp() { + spuMapper = mock(CanteenSpuMapper.class); + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + valueOperations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + CanteenSpuServiceImpl target = new CanteenSpuServiceImpl(redisTemplate, null, null, null, null, null, + mock(CanteenMerchantScopeHelper.class)); + ReflectionTestUtils.setField(target, "baseMapper", spuMapper); + service = target; + TransactionSynchronizationManager.initSynchronization(); + } + + /** 清理线程级事务同步状态,避免测试间相互污染。 */ + @AfterEach + void tearDown() { + TransactionSynchronizationManager.clearSynchronization(); + } + + /** + * 已确认应用后完成补偿的售后库存允许再次 APPLY;否则钱包成功后的任务重试会丢失库存恢复。 + */ + @Test + void compensatedRefundInventoryShouldAllowRetry() { + CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", 2, 10); + when(valueOperations.get("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9")) + .thenReturn("COMPENSATED"); + when(spuMapper.selectById(1L)).thenReturn(spu(10)); + when(spuMapper.updateStockIfMatch(1L, 2, 10)).thenReturn(1); + + assertTrue(service.updateSkuStockIdempotent(dto).isOk()); + + verify(spuMapper).updateStockIfMatch(1L, 2, 10); + verify(valueOperations).set("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9", "APPLIED", + Duration.ofDays(7)); + } + + /** + * 已执行 APPLY 的回滚必须标记为可重试补偿态,区别于阻断网络延迟 APPLY 的未确认回滚态。 + */ + @Test + void confirmedApplyRollbackShouldBecomeCompensated() { + CanteenSpuUpdateStockReqDTO dto = stockRequest("ROLLBACK", 2, 10); + when(valueOperations.get("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9")) + .thenReturn("APPLIED"); + when(spuMapper.selectById(1L)).thenReturn(spu(12)); + when(spuMapper.updateStock(1L, -2)).thenReturn(1); + + assertTrue(service.updateSkuStockIdempotent(dto).isOk()); + + verify(spuMapper).updateStock(1L, -2); + verify(valueOperations).set("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9", "COMPENSATED", + Duration.ofDays(7)); + } + + /** + * 构造单个基础 SPU 的幂等库存请求。 + * + * @param operation APPLY 或 ROLLBACK + * @param increment 原 APPLY 使用的库存增量 + * @param beforeStock 当前业务尝试的操作前库存快照 + * @return 可直接调用库存服务的请求 + */ + private CanteenSpuUpdateStockReqDTO stockRequest(String operation, int increment, int beforeStock) { + CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item(); + item.setId(1L); + item.setIncrCount(increment); + item.setBeforeStock(beforeStock); + CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO(); + dto.setBizId("HARD_DAILY_MENU_AFTER_SALE:9"); + dto.setOperation(operation); + dto.setItems(List.of(item)); + return dto; + } + + /** + * 构造当前库存快照。 + * + * @param stock 当前基础 SPU 库存 + * @return 仅包含本用例所需字段的实体 + */ + private CanteenSpuEntity spu(int stock) { + CanteenSpuEntity entity = new CanteenSpuEntity(); + entity.setId(1L); + entity.setStock(stock); + entity.setName("测试菜品"); + return entity; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardCanteenStatisticsLevelEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardCanteenStatisticsLevelEnum.java new file mode 100644 index 00000000..673352bb --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardCanteenStatisticsLevelEnum.java @@ -0,0 +1,44 @@ +package com.bsd.pjcloud.hard.api.constant.order; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 食堂销售统计展示层级协议。 + * + *

该编码仅用于接口返回和管理端组织多维统计行,不对应数据库字段;调用方不得将名称作为稳定判断条件。

+ */ +@Getter +@AllArgsConstructor +public enum HardCanteenStatisticsLevelEnum { + + /** 按自然日汇总全部餐段和当前来源筛选范围。 */ + DATE_TOTAL("DATE_TOTAL", "当日合计"), + + /** 按自然日和餐段汇总当前来源筛选范围。 */ + HOUR_TOTAL("HOUR_TOTAL", "餐段合计"), + + /** 按自然日拆分来源,供当日合计行横向比较。 */ + DATE_SOURCE("DATE_SOURCE", "当日来源"), + + /** 按自然日、餐段拆分来源,供餐段合计行横向比较。 */ + HOUR_SOURCE("HOUR_SOURCE", "餐段来源"); + + /** 前后端接口使用的稳定层级编码,不写入数据库。 */ + private final String code; + + /** 层级的中文展示名称,仅用于界面说明。 */ + private final String description; + + /** + * 根据接口层级编码查找枚举。 + * + * @param code 管理端统计接口返回的层级编码;为空或未知时不匹配 + * @return 匹配的层级枚举,未匹配时返回 {@code null} + */ + public static HardCanteenStatisticsLevelEnum valueOfCode(String code) { + return Arrays.stream(values()).filter(value -> value.code.equals(code)).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceRoleEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceRoleEnum.java new file mode 100644 index 00000000..64e0846f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceRoleEnum.java @@ -0,0 +1,58 @@ +package com.bsd.pjcloud.hard.api.constant.order; + +import lombok.Getter; + +import java.util.Arrays; + +/** + * 订单证据图片角色,编码用于唯一位置约束和跨端展示。 + */ +@Getter +public enum HardOrderEvidenceRoleEnum { + + /** + * 人脸库中被命中的后台参考人脸,由服务端从受信文件中心复制为订单快照。 + */ + FACE_REFERENCE("FACE_REFERENCE", "后台参考人脸"), + + /** + * 终端实际通过比对的现场人脸裁剪图,由终端异步上传。 + */ + FACE_LIVE("FACE_LIVE", "现场识别人脸"), + + /** + * 菜谱识别的现场餐盘图片预留角色,当前版本不创建该类证据。 + */ + MENU_LIVE("MENU_LIVE", "餐盘现场图"); + + /** + * 数据库和接口使用的稳定编码。 + */ + private final String code; + + /** + * PC 订单详情使用的中文名称。 + */ + private final String description; + + /** + * 构造订单证据图片角色。 + * + * @param code 数据库与跨端协议编码 + * @param description 中文展示名称 + */ + HardOrderEvidenceRoleEnum(String code, String description) { + this.code = code; + this.description = description; + } + + /** + * 按协议编码解析图片角色,未知编码返回 {@code null}。 + * + * @param code 数据库或接口编码 + * @return 对应角色;未知时返回 {@code null} + */ + public static HardOrderEvidenceRoleEnum valueOfCode(String code) { + return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceStatusEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceStatusEnum.java new file mode 100644 index 00000000..f74b4c13 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceStatusEnum.java @@ -0,0 +1,58 @@ +package com.bsd.pjcloud.hard.api.constant.order; + +import lombok.Getter; + +import java.util.Arrays; + +/** + * 服务端订单证据状态,表达证据是否仍待补传、已经可查看或已永久缺失。 + */ +@Getter +public enum HardOrderEvidenceStatusEnum { + + /** + * 订单已记录识别元数据,但图片仍等待终端上传或服务端复制。 + */ + PENDING("PENDING", "待补传"), + + /** + * 文件中心已经保存订单专属图片并可在订单详情中查看。 + */ + AVAILABLE("AVAILABLE", "可查看"), + + /** + * 本地文件过期、损坏或参考图片变化,已确认无法继续补齐。 + */ + UNAVAILABLE("UNAVAILABLE", "永久缺失"); + + /** + * 数据库和接口使用的稳定编码。 + */ + private final String code; + + /** + * PC 订单详情使用的中文名称。 + */ + private final String description; + + /** + * 构造订单证据状态。 + * + * @param code 数据库与跨端协议编码 + * @param description 中文展示名称 + */ + HardOrderEvidenceStatusEnum(String code, String description) { + this.code = code; + this.description = description; + } + + /** + * 按协议编码解析证据状态,未知编码返回 {@code null}。 + * + * @param code 数据库或接口编码 + * @return 对应状态;未知时返回 {@code null} + */ + public static HardOrderEvidenceStatusEnum valueOfCode(String code) { + return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceTypeEnum.java new file mode 100644 index 00000000..53101e28 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceTypeEnum.java @@ -0,0 +1,53 @@ +package com.bsd.pjcloud.hard.api.constant.order; + +import lombok.Getter; + +import java.util.Arrays; + +/** + * 订单证据业务类型,编码同时用于数据库、终端 SQLite 和跨端接口,禁止随展示文案调整。 + */ +@Getter +public enum HardOrderEvidenceTypeEnum { + + /** + * 人脸识别证据,当前版本保存后台参考人脸和现场识别人脸。 + */ + FACE_RECOGNITION("FACE_RECOGNITION", "人脸识别"), + + /** + * 菜谱识别证据预留值,当前版本不执行菜谱识别或图片采集。 + */ + MENU_RECOGNITION("MENU_RECOGNITION", "菜谱识别"); + + /** + * 数据库和接口使用的稳定编码。 + */ + private final String code; + + /** + * PC 订单详情使用的中文名称。 + */ + private final String description; + + /** + * 构造订单证据业务类型。 + * + * @param code 数据库与跨端协议编码 + * @param description 中文展示名称 + */ + HardOrderEvidenceTypeEnum(String code, String description) { + this.code = code; + this.description = description; + } + + /** + * 按协议编码解析证据类型,未知编码返回 {@code null} 供调用方拒绝非法请求。 + * + * @param code 数据库或接口编码 + * @return 对应类型;未知时返回 {@code null} + */ + public static HardOrderEvidenceTypeEnum valueOfCode(String code) { + return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardSaleSourceEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardSaleSourceEnum.java new file mode 100644 index 00000000..293faed0 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardSaleSourceEnum.java @@ -0,0 +1,57 @@ +package com.bsd.pjcloud.hard.api.constant.order; + +import lombok.Getter; + +import java.util.Arrays; +import java.util.List; + +/** + * 食堂销售统计来源。 + * + *

编码只用于统计查询和管理端展示,不写入订单表;来源由订单类型、预约关联、订单状态以及对应业务时间计算得到。

+ */ +@Getter +public enum HardSaleSourceEnum { + + /** 订单类型 1 的线下购买,统计时间取订单支付时间。 */ + OFFLINE_PURCHASE("OFFLINE_PURCHASE", "线下购买"), + + /** 订单类型 2 且已完成核销的预约,统计时间取订单核销时间。 */ + RESERVATION_VERIFIED("RESERVATION_VERIFIED", "预约核销"); + + /** 统计接口和前端标签页使用的来源编码。 */ + private final String code; + + /** 来源中文名称。 */ + private final String description; + + /** + * 创建稳定的统计来源协议项;编码只用于接口筛选,不对应数据库持久化值。 + * + * @param code 前后端共同使用的来源编码 + * @param description 管理端与导出使用的中文名称 + */ + HardSaleSourceEnum(String code, String description) { + this.code = code; + this.description = description; + } + + /** + * 返回全部可筛选来源编码。 + * + * @return 来源编码列表,顺序与管理端标签页一致 + */ + public static List getCodes() { + return Arrays.stream(values()).map(HardSaleSourceEnum::getCode).toList(); + } + + /** + * 按统计来源编码解析枚举。 + * + * @param code 统计接口传入的来源编码 + * @return 匹配的来源;为空或未知编码返回 {@code null} + */ + public static HardSaleSourceEnum valueOfCode(String code) { + return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java index 6e1edc41..d90891ce 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java @@ -33,6 +33,9 @@ public enum HardAfterSaleOperateTypeEnum { /** 系统根据订单快照和商户配置免审完成退款。 */ SYSTEM_AUTO_REFUND("31", "系统自动退款"), + /** 系统按每日菜谱预约过期快照创建整单退款。 */ + SYSTEM_EXPIRED_RESERVE_REFUND("32", "系统过期预约退款"), + /** 会员取消尚未审核的退款申请。 */ MEMBER_CANCEL("40", "会员取消退款"), ; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskTypeEnum.java new file mode 100644 index 00000000..fa11785b --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskTypeEnum.java @@ -0,0 +1,33 @@ +package com.bsd.pjcloud.hard.api.constant.refund; + +import lombok.Getter; + +/** + * 自动退款任务场景;编码持久化到任务表并用于区分不同的配置和售后创建规则。 + */ +@Getter +public enum HardAutoRefundTaskTypeEnum { + + /** 用户申请免审退款后创建的技术自动退款任务。 */ + AFTER_SALE("10", "售后申请自动退款"), + + /** 每日菜谱预约餐段结束后创建的系统过期退款任务。 */ + EXPIRED_RESERVE("20", "过期预约自动退款"); + + /** 数据库存储的稳定任务场景编码。 */ + private final String type; + + /** 管理端展示名称。 */ + private final String description; + + /** + * 构造任务场景协议项。 + * + * @param type 数据库存储编码 + * @param description 展示名称 + */ + HardAutoRefundTaskTypeEnum(String type, String description) { + this.type = type; + this.description = description; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java index 31e6da14..b28ea530 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java @@ -21,7 +21,12 @@ public enum HardAutoRefundTriggerTypeEnum { /** * 管理端技术人员对耗尽任务执行的一次额外尝试。 */ - MANUAL("30", "人工技术重试", false); + MANUAL("30", "人工技术重试", false), + + /** + * 管理端业务人员对已到期候选订单确认后执行的逐单退款,不复用技术重试权限。 + */ + EXPIRED_MANUAL("40", "后台逐单过期退款", false); /** * 数据库存储的稳定触发编码。 diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardCreateOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardCreateOrderDto.java index f3169e9e..f0be9f4d 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardCreateOrderDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardCreateOrderDto.java @@ -4,7 +4,9 @@ import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; import com.bsd.pjcloud.hard.api.dto.order.HardCreateCarDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceMetadataDto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; @@ -75,4 +77,17 @@ public class HardCreateOrderDto { @Schema(description = "离线支付是否成功") private Boolean offlinePaySuccess; + + /** + * 当前终端应用版本,仅用于订单证据问题追溯,不参与订单计价和支付。 + */ + @Schema(description = "终端应用版本") + private String appVersion; + + /** + * 人脸支付成功后需要挂接的证据元数据;旧终端不传时保持订单创建协议兼容。 + */ + @Schema(description = "订单识别证据元数据") + @Valid + private List evidenceMetadataList; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAutoRefundTaskPageDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAutoRefundTaskPageDto.java index a4594a3a..5808071b 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAutoRefundTaskPageDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAutoRefundTaskPageDto.java @@ -43,6 +43,10 @@ public class HardAutoRefundTaskPageDto { @Schema(description = "任务状态") private String status; + /** 任务场景编码;候选列表固定筛选过期预约任务。 */ + @Schema(description = "任务场景") + private String taskType; + /** * 任务创建时间闭区间,数组长度应为 2。 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java index e8384b42..d16c4ae4 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java @@ -1,6 +1,9 @@ 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.BaseIsIncludeTreeInterface; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -17,7 +20,7 @@ import java.util.List; */ @Data @Schema(description = "食堂用户预约查询条件") -public class HardCanteenUserApptQueryDto implements BaseIsIncludeTreeInterface { +public class HardCanteenUserApptQueryDto implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface { /** 用餐日期闭区间,固定为开始、结束两个服务端本地日期,业务层限制跨度最多为两个自然月。 */ @NotNull(message = "用餐日期范围不能为空") @@ -29,6 +32,22 @@ public class HardCanteenUserApptQueryDto implements BaseIsIncludeTreeInterface { @Schema(description = "用户姓名、登录账号或手机号") private String nameOrPhone; + /** 用户身份协议编码集合;为空时不限制身份类型,编码由 UPMS 身份表和前端字典共同约束。 */ + @Schema(description = "用户身份类型集合") + private List identityTypes; + + /** 学生身份扩展查询条件;字段匹配语义沿用 UPMS 用户查询。 */ + @Schema(description = "学生身份扩展查询条件") + private SysUserStudentDto studentInfo; + + /** 老师身份扩展查询条件;老师学号为空时不限制老师资料。 */ + @Schema(description = "老师身份扩展查询条件") + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态筛选:1 表示存在未删除人脸,0 表示不存在,空值表示不限制。 */ + @Schema(description = "是否已录入人脸:1已录入,0未录入") + private String hasFace; + /** 部门树选中的部门主键集合;为空时使用当前操作者完整的数据权限范围。 */ @Schema(description = "部门主键集合") private List deptIds; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java index 7c1ea2a2..d5699377 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java @@ -26,11 +26,23 @@ public class HardCarDto extends HardCarEntity implements BaseIsIncludeTreeInterf private Long merchantId; + /** 订单类型协议编码;详情查询传预约编码时仅统计预约订单,其他场景为空表示不限定。 */ + private String orderType; + + /** 是否只保留关联预约规则或预约记录的订单;为空或 false 时不增加预约关联限制。 */ + private Boolean apptIdOrApptRuleIdNotNull; + private List mchIds; /** 预计使用日期范围。 */ private LocalDateTime[] apptUseDateTimeList; + /** 菜品销售统计日期范围,线下按支付时间、预约按核销时间过滤;不落库。 */ + private LocalDateTime[] statisticsDateTimeList; + + /** 菜品销售统计来源编码集合,取值来自 HardSaleSourceEnum;为空表示全部来源。 */ + private List saleSourceCodes; + /** * 前端树搜索 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java index e7ba33ec..a25062a1 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java @@ -38,11 +38,29 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn private LocalDateTime[] createTimeList; - /** 更新时间范围采用左闭右开区间,供按订单实际状态变更时间查询。 */ + /** 更新时间范围采用左闭右开区间,供兼容旧调用方按订单状态变更时间查询。 */ private LocalDateTime[] updateTimeList; + /** 订单核销时间闭区间,仅按订单实际核销完成时间查询。 */ + private LocalDateTime[] verificationTimeList; + private LocalDateTime[] payTimeList; + /** + * 食堂销售统计日期范围,线下来源按支付时间、预约来源按核销时间过滤;不落库。 + */ + private LocalDateTime[] statisticsDateTimeList; + + /** + * 食堂销售统计来源编码集合,取值来自 HardSaleSourceEnum;为空表示同时查询两种来源。 + */ + private List saleSourceCodes; + + /** + * 商品名称模糊查询条件,仅供购物车商品统计 SQL 筛选使用,不对应订单表持久化字段。 + */ + private String productName; + private LocalDateTime[] refundTimeList; /** 预计使用日期范围。 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceMetadataDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceMetadataDto.java new file mode 100644 index 00000000..585bbd7b --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceMetadataDto.java @@ -0,0 +1,130 @@ +package com.bsd.pjcloud.hard.api.dto.order; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 订单创建和证据补传共用的识别元数据,不包含图片字节和服务端文件信息。 + */ +@Data +@Schema(description = "订单识别证据元数据") +public class HardOrderEvidenceMetadataDto { + + /** + * 证据业务类型,使用 HardOrderEvidenceTypeEnum 的稳定编码。 + */ + @NotBlank(message = "证据类型不能为空") + @Size(max = 32, message = "证据类型不能超过32个字符") + @Schema(description = "证据类型") + private String evidenceType; + + /** + * 图片角色,使用 HardOrderEvidenceRoleEnum 的稳定编码。 + */ + @NotBlank(message = "证据图片角色不能为空") + @Size(max = 32, message = "证据图片角色不能超过32个字符") + @Schema(description = "证据图片角色") + private String evidenceRole; + + /** + * 同类型同角色下的图片序号,从0开始;人脸证据固定为0,菜谱识别可递增。 + */ + @NotNull(message = "证据图片序号不能为空") + @Min(value = 0, message = "证据图片序号不能小于0") + @Max(value = 99, message = "证据图片序号不能大于99") + @Schema(description = "证据图片序号") + private Integer sequenceNo; + + /** + * 识别用户主键;未识别到平台用户时为空,不能由终端使用其他用户替代。 + */ + @Schema(description = "识别用户主键") + private Long userId; + + /** + * 虹软搜索样本编号;仅人脸识别角色使用,服务端据此校验后台参考人脸。 + */ + @Size(max = 64, message = "人脸样本编号不能超过64个字符") + @Schema(description = "人脸样本编号") + private String faceSampleId; + + /** + * 识别时终端人脸库记录的参考图片地址,仅作为服务端复制参考图的线索,不直接作为可访问地址返回。 + */ + @Size(max = 512, message = "参考人脸地址不能超过512个字符") + @Schema(description = "参考人脸地址") + private String referenceImageUrl; + + /** + * 识别时终端人脸快照对应的后台更新时间;旧终端允许为空,新终端用于识别同地址内容替换。 + */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss[.SSS]", timezone = "Asia/Shanghai") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + @Schema(description = "参考人脸来源版本") + private LocalDateTime referenceImageVersion; + + /** + * 识别时实际相似度,取值0到1;为空表示当前证据类型不使用相似度。 + */ + @DecimalMin(value = "0", message = "相似度不能小于0") + @DecimalMax(value = "1", message = "相似度不能大于1") + @Schema(description = "识别相似度") + private BigDecimal similarity; + + /** + * 识别时终端生效的相似度阈值快照,取值0到1。 + */ + @DecimalMin(value = "0", message = "相似度阈值不能小于0") + @DecimalMax(value = "1", message = "相似度阈值不能大于1") + @Schema(description = "相似度阈值快照") + private BigDecimal similarityThreshold; + + /** + * 终端实际完成识别的本地时间;为空时服务端仅保留订单创建时间。 + */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss[.SSS]", timezone = "Asia/Shanghai") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + @Schema(description = "终端采集时间") + private LocalDateTime capturedAt; + + /** + * 终端按原始证据文件计算的 SHA-256;图片尚未生成或永久缺失时允许为空。 + */ + @Pattern(regexp = "(?i)^[0-9a-f]{64}$", message = "图片哈希必须为64位SHA-256") + @Schema(description = "图片SHA-256") + private String imageHash; + + /** + * 终端证据文件大小,单位字节;参考图无法读取或文件尚未生成时允许为空。 + */ + @Min(value = 0, message = "图片大小不能小于0") + @Schema(description = "图片大小,单位字节") + private Long imageSize; + + /** + * 终端解码后的图片宽度,单位像素。 + */ + @Min(value = 1, message = "图片宽度必须大于0") + @Schema(description = "图片宽度,单位像素") + private Integer imageWidth; + + /** + * 终端解码后的图片高度,单位像素。 + */ + @Min(value = 1, message = "图片高度必须大于0") + @Schema(description = "图片高度,单位像素") + private Integer imageHeight; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceOrderRefDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceOrderRefDto.java new file mode 100644 index 00000000..510a7e4e --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceOrderRefDto.java @@ -0,0 +1,45 @@ +package com.bsd.pjcloud.hard.api.dto.order; + +import cn.hutool.core.util.StrUtil; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 终端订单识别证据的订单定位参数。 + * + *

消费机现场下单继续使用终端订单号对账;预约核销订单不是当前终端创建的,核销成功后改用 + * 服务端订单 ID 定位。两种方式都必须结合请求头中的设备 ID 校验订单归属。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "订单识别证据的订单定位参数") +public abstract class HardOrderEvidenceOrderRefDto extends HardOrderEvidenceMetadataDto { + + /** + * 终端本地稳定订单号,用于消费机现场下单后对账;预约核销证据允许为空。 + */ + @Size(max = 128, message = "终端订单号不能超过128个字符") + @Schema(description = "终端订单号,与服务端订单ID二选一") + private String androidOrderCode; + + /** + * 已存在的服务端订单主键,仅用于当前设备已经成功核销的预约订单;现场下单证据允许为空。 + */ + @Positive(message = "服务端订单ID必须大于0") + @Schema(description = "服务端订单ID,与终端订单号二选一") + private Long orderId; + + /** + * 校验至少提供一种订单定位信息,防止无条件查询或写入订单证据。 + * + * @return 提供终端订单号或服务端订单 ID 时返回 {@code true} + */ + @AssertTrue(message = "终端订单号和服务端订单ID不能同时为空") + public boolean isOrderReferencePresent() { + return StrUtil.isNotBlank(androidOrderCode) || orderId != null; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceSubmitDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceSubmitDto.java new file mode 100644 index 00000000..343b0978 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceSubmitDto.java @@ -0,0 +1,38 @@ +package com.bsd.pjcloud.hard.api.dto.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 终端提交订单证据元数据或永久缺失结果的请求。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "订单证据元数据提交请求") +public class HardOrderEvidenceSubmitDto extends HardOrderEvidenceOrderRefDto { + + /** + * 终端请求写入的目标状态,仅允许 PENDING 或 UNAVAILABLE;现场图片上传成功由服务端改为 AVAILABLE。 + */ + @NotBlank(message = "证据状态不能为空") + @Size(max = 32, message = "证据状态不能超过32个字符") + @Schema(description = "证据状态") + private String status; + + /** + * 永久缺失原因;状态为 UNAVAILABLE 时必填,禁止包含本地完整敏感路径。 + */ + @Size(max = 500, message = "失败原因不能超过500个字符") + @Schema(description = "永久缺失原因") + private String failureReason; + + /** + * 产生识别证据的终端应用版本,用于问题追溯。 + */ + @Size(max = 64, message = "应用版本不能超过64个字符") + @Schema(description = "终端应用版本") + private String appVersion; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceUploadDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceUploadDto.java new file mode 100644 index 00000000..4b8f3363 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceUploadDto.java @@ -0,0 +1,22 @@ +package com.bsd.pjcloud.hard.api.dto.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 终端上传订单现场证据图片时随 multipart 一并提交的元数据。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "订单现场证据上传请求") +public class HardOrderEvidenceUploadDto extends HardOrderEvidenceOrderRefDto { + + /** + * 产生识别证据的终端应用版本,用于问题追溯。 + */ + @Size(max = 64, message = "应用版本不能超过64个字符") + @Schema(description = "终端应用版本") + private String appVersion; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java index 21024beb..ad5e6842 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java @@ -18,7 +18,8 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; /** - * Hardware 自动退款持久化任务。一个售后单最多创建一条任务,失败后由该记录持续驱动恢复。 + * Hardware 自动退款持久化任务。售后免审任务创建时绑定售后单,过期预约任务在实际执行前允许暂不绑定, + * 失败后由该记录持续驱动恢复。 */ @Data @Builder @@ -38,11 +39,15 @@ public class HardAutoRefundTaskEntity extends Model { private Long id; /** - * 售后单 ID,是任务唯一业务键和钱包退款幂等键。 + * 售后单 ID;普通售后任务创建时必填,过期预约任务创建系统售后单前为空,完成后作为钱包退款幂等键。 */ @Schema(description = "售后单ID") private Long afterSaleId; + /** 任务场景编码;10为用户免审售后,20为过期预约退款。 */ + @Schema(description = "任务场景") + private String taskType; + /** * 原 Hardware 订单 ID,用于订单级分布式锁和退款状态聚合。 */ @@ -73,6 +78,14 @@ public class HardAutoRefundTaskEntity extends Model { @Schema(description = "免审资格截止时间") private LocalDateTime eligibilityDeadline; + /** 过期预约任务的订单快照触发时间;旧售后任务为空。 */ + @Schema(description = "过期退款触发时间") + private LocalDateTime triggerTime; + + /** 任务创建时快照的退库存开关,1恢复、0不恢复。 */ + @Schema(description = "退库存快照") + private String restoreInventory; + /** * 当前任务状态编码,对应 HardAutoRefundTaskStatusEnum。 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java index b866cf6c..d124723d 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java @@ -159,6 +159,22 @@ public class HardOrderEntity extends Model { @Schema(description = "自动退款申请截止时间") private LocalDateTime autoRefundDeadline; + /** 每日菜谱过期退款配置 ID 快照;仅支付成功且下单时开关开启的新订单写入。 */ + @Schema(description = "过期预约退款配置ID快照") + private Long expiredRefundConfigId; + + /** 预约订单支付成功时解析出的实际餐段结束时间;为空表示无法安全确定过期资格。 */ + @Schema(description = "过期预约餐段实际结束时间快照") + private LocalDateTime expiredRefundUsageEndTime; + + /** 实际餐段结束时间加配置延迟后的过期退款触发时间。 */ + @Schema(description = "过期预约退款触发时间") + private LocalDateTime expiredRefundDeadline; + + /** 过期退款是否恢复库存的订单快照,1恢复、0不恢复。 */ + @Schema(description = "过期预约退款退库存快照") + private String expiredRefundRestoreInventory; + /** * 订单设备类型 */ @@ -219,6 +235,12 @@ public class HardOrderEntity extends Model { @Schema(description = "退款状态") private String refundStatus; + /** + * 订单核销完成时间;仅在订单实际完成核销时写入,避免被后续退款等订单更新覆盖。 + */ + @Schema(description = "订单核销时间") + private LocalDateTime verificationTime; + /** * 订单取消时间(退款订单) */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEvidenceImageEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEvidenceImageEntity.java new file mode 100644 index 00000000..e7e6c59b --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEvidenceImageEntity.java @@ -0,0 +1,208 @@ +package com.bsd.pjcloud.hard.api.entity.order; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 订单识别证据图片,从属于 hard_order 并长期保存争议处理所需的文件快照和识别元数据。 + */ +@Data +@TenantTable +@TableName("hard_order_evidence_image") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "订单识别证据图片") +public class HardOrderEvidenceImageEntity extends Model { + + /** + * 证据记录自增主键。 + */ + @TableId(type = IdType.AUTO) + @Schema(description = "证据记录主键") + private Long id; + + /** + * 服务端订单主键,来源于 hard_order.id。 + */ + @Schema(description = "订单主键") + private Long orderId; + + /** + * 证据订单引用快照;现场订单取终端订单号,预约等服务端订单取订单号或服务端主键内部引用。 + */ + @Schema(description = "证据订单引用快照") + private String androidOrderCode; + + /** + * 证据业务类型,保存 HardOrderEvidenceTypeEnum.code。 + */ + @Schema(description = "证据类型") + private String evidenceType; + + /** + * 图片角色,保存 HardOrderEvidenceRoleEnum.code。 + */ + @Schema(description = "证据图片角色") + private String evidenceRole; + + /** + * 同一订单、类型和角色下的图片序号,从0开始。 + */ + @Schema(description = "证据图片序号") + private Integer sequenceNo; + + /** + * 识别用户主键快照;无法识别到平台用户时允许为空。 + */ + @Schema(description = "识别用户主键") + private Long userId; + + /** + * 虹软人脸样本编号快照,来源于 sys_user_face.auto_code。 + */ + @Schema(description = "人脸样本编号") + private String faceSampleId; + + /** + * 识别时终端命中的后台参考图片地址快照,仅用于与 UPMS 权威记录比对,不直接作为展示地址。 + */ + @Schema(description = "参考人脸来源地址快照") + private String referenceImageUrl; + + /** + * 终端识别相似度快照,取值0到1。 + */ + @Schema(description = "识别相似度") + private BigDecimal similarity; + + /** + * 终端识别相似度阈值快照,取值0到1。 + */ + @Schema(description = "识别相似度阈值") + private BigDecimal similarityThreshold; + + /** + * 产生证据的服务端设备主键,来源于 hard_order.hard_id。 + */ + @Schema(description = "设备主键") + private Long hardId; + + /** + * 终端实际完成识别的本地时间,用于与订单时间交叉核验。 + */ + @Schema(description = "终端采集时间") + private LocalDateTime capturedAt; + + /** + * 文件中心 sys_file 主键;状态非 AVAILABLE 时允许为空。 + */ + @Schema(description = "文件中心记录主键") + private Long sysFileId; + + /** + * 文件中心对象名,仅供服务端读取或下载,不由终端提交。 + */ + @Schema(description = "文件中心对象名") + private String storageFileName; + + /** + * PC 可使用统一请求层访问的文件相对地址。 + */ + @Schema(description = "图片访问地址") + private String imageUrl; + + /** + * 实际保存文件的 SHA-256,用于幂等和防止证据被静默替换。 + */ + @Schema(description = "图片SHA-256") + private String imageHash; + + /** + * 实际保存文件大小,单位字节。 + */ + @Schema(description = "图片大小,单位字节") + private Long imageSize; + + /** + * 实际解码图片宽度,单位像素。 + */ + @Schema(description = "图片宽度,单位像素") + private Integer imageWidth; + + /** + * 实际解码图片高度,单位像素。 + */ + @Schema(description = "图片高度,单位像素") + private Integer imageHeight; + + /** + * 证据状态,保存 HardOrderEvidenceStatusEnum.code。 + */ + @Schema(description = "证据状态") + private String status; + + /** + * 永久缺失或服务端复制失败的摘要,不保存终端完整文件路径。 + */ + @Schema(description = "失败原因") + private String failureReason; + + /** + * 产生证据的终端应用版本快照。 + */ + @Schema(description = "终端应用版本") + private String appVersion; + + /** + * 创建人,由 MyBatis 填充器写入。 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建人") + private String createBy; + + /** + * 证据占位记录创建时间,由 MyBatis 填充器写入。 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + /** + * 修改人,由 MyBatis 填充器在状态变化时写入。 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改人") + private String updateBy; + + /** + * 最近一次状态、文件或错误信息更新时间。 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改时间") + private LocalDateTime updateTime; + + /** + * 逻辑删除标记;订单退款、取消和删除不会联动改变该值。 + */ + @TableLogic + @TableField(fill = FieldFill.INSERT) + @Schema(description = "删除标记") + private String delFlag; + + /** + * 租户主键,由当前请求租户上下文写入并由数据层自动隔离。 + */ + @Schema(description = "租户主键") + private Long tenantId; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java index cb1fec68..49b3e4ad 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java @@ -15,6 +15,24 @@ public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserInte */ private String autoRefundTaskStatus; + /** + * 自动退款任务场景编码;10 表示规定时间内用户免审退款,20 表示过期订单自动退款,人工审核时为空。 + * 该字段仅用于管理端列表响应,不对应售后表持久化字段。 + */ + private String autoRefundTaskType; + + /** + * 用户免审退款采用的规则名称,来源于自动退款任务创建时的配置快照;非免审任务为空。 + * 该字段仅用于管理端列表响应,不对应售后表持久化字段。 + */ + private String autoRefundRuleType; + + /** + * 用户免审退款资格截止时间,来源于自动退款任务创建时的规则快照;非免审任务为空。 + * 该字段仅用于管理端列表响应,不对应售后表持久化字段。 + */ + private LocalDateTime autoRefundEligibilityDeadline; + /** * 用户相关 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java index bb2c3106..e44b13d4 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java @@ -42,4 +42,12 @@ public class HardAutoRefundTaskVo extends HardAutoRefundTaskEntity { */ @Schema(description = "售后状态") private String afterSaleStatus; + + /** 预约订单实际使用日期,过期候选列表展示用。 */ + @Schema(description = "预约日期") + private java.time.LocalDate apptUseDate; + + /** 预约餐段编码,过期候选列表展示用。 */ + @Schema(description = "餐段") + private String hourType; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java new file mode 100644 index 00000000..96f057a1 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java @@ -0,0 +1,46 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import lombok.Data; + +/** + * 食堂菜品销售三级导出的聚合记录。 + * + *

同一模型承载日期餐段、商户部门和商品三种聚合查询结果,商品聚合时额外填充商品字段; + * 统计时间已按销售来源统一转换为业务发生日期,金额统一使用分。

+ */ +@Data +public class HardCanteenProductApptExportRecord { + + /** 业务发生日期,格式为 yyyy-MM-dd;线下取支付日期,预约取核销日期。 */ + private String statisticsDate; + + /** 用餐时间段协议编码。 */ + private String hourType; + + /** 销售来源协议编码;为空表示当前筛选范围内的跨来源合计。 */ + private String saleSource; + + /** 食堂商户主键。 */ + private Long merchantId; + + /** 购买用户所属部门主键。 */ + private Long userDeptId; + + /** 商品主键;商户部门聚合记录为空。 */ + private Long productId; + + /** 订单快照中的商品名称;商户部门聚合记录为空。 */ + private String productName; + + /** 当前统计层级内去重后的交易人数。 */ + private Long payUserCount; + + /** 当前统计层级内去重后的订单数量。 */ + private Long timeCount; + + /** 有效商品总数量,预约来源已扣减退款完成数量。 */ + private Long payCount; + + /** 有效商品总价,单位为分;预约来源已扣减退款完成金额。 */ + private Long totalPrice; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductTotalVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductTotalVo.java index 3543aa62..d5af79b1 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductTotalVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductTotalVo.java @@ -42,9 +42,31 @@ public class HardCanteenProductTotalVo { @Schema(description = "购买次数") private Long timeCount; + /** 统计来源编码,来源于 HardSaleSourceEnum,不对应数据库字段。 */ + private String saleSource; + + /** 统计来源名称,用于管理端和导出展示,不对应数据库字段。 */ + private String saleSourceName; + + /** + * 统计展示层级,取值来自 HardCanteenStatisticsLevelEnum,不对应数据库字段。 + * 空值表示兼容旧统计接口返回结构。 + */ + private String statisticsLevel; + + /** 统计展示层级中文名称,用于管理端说明当前行为日期合计或餐段合计。 */ + private String statisticsLevelName; + + /** + * 当前合计行的来源拆分结果;管理端用它在同一行比较线下购买与预约核销。 + * 空集合表示当前筛选范围没有来源明细或属于旧统计接口。 + */ + private List sourceRows; + @Schema(description = "用餐部门总数") private Integer deptCount; + /** 菜品统计中承载餐段合计行,部门统计中承载部门餐段行;空值表示无下一级统计。 */ private List children; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDailyStatisticsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDailyStatisticsVo.java new file mode 100644 index 00000000..47c433fa --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDailyStatisticsVo.java @@ -0,0 +1,20 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDate; + +/** + * 指定用餐日期的用户预约统计。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "食堂用户每日预约统计") +public class HardCanteenUserApptDailyStatisticsVo extends HardCanteenUserApptStatisticsSummaryVo { + + /** 统计对应的用餐日期,格式由接口序列化器输出为 yyyy-MM-dd。 */ + @Schema(description = "用餐日期") + private LocalDate date; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishStatisticsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishStatisticsVo.java new file mode 100644 index 00000000..2fbd187d --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishStatisticsVo.java @@ -0,0 +1,32 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 用户预约按餐段和菜品快照聚合的统计数据。 + */ +@Data +@Schema(description = "食堂用户预约菜品统计") +public class HardCanteenUserApptDishStatisticsVo { + + /** 餐段字典编码,空值表示历史订单未记录餐段。 */ + @Schema(description = "餐段编码") + private String hourType; + + /** 下单时固化的菜品名称;历史空值由服务层归一为未命名菜品。 */ + @Schema(description = "菜品名称") + private String productName; + + /** 菜品快照的总份数。 */ + @Schema(description = "菜品份数") + private Long productCount = 0L; + + /** 包含该菜品的去重预约订单数。 */ + @Schema(description = "涉及订单数") + private Long orderCount = 0L; + + /** 该菜品快照总价,单位为分。 */ + @Schema(description = "菜品总价,单位为分") + private Long totalPrice = 0L; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptMealStatisticsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptMealStatisticsVo.java new file mode 100644 index 00000000..dcd15ddd --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptMealStatisticsVo.java @@ -0,0 +1,32 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 用户预约按餐段聚合的统计数据。 + */ +@Data +@Schema(description = "食堂用户预约餐段统计") +public class HardCanteenUserApptMealStatisticsVo { + + /** 餐段字典编码,属于 PC、App 与后端共用协议。 */ + @Schema(description = "餐段编码") + private String hourType; + + /** 该餐段去重后的用户-用餐日期数;每日表示人数,范围总计表示可与每日相加的订餐人次。 */ + @Schema(description = "预约人数或人次") + private Long reservedUserCount = 0L; + + /** 该餐段的有效预约订单数。 */ + @Schema(description = "预约订单数") + private Long orderCount = 0L; + + /** 该餐段购物车快照中的菜品总份数。 */ + @Schema(description = "菜品份数") + private Long dishCount = 0L; + + /** 该餐段有效预约商品总价,单位为分。 */ + @Schema(description = "预约总价,单位为分") + private Long totalPrice = 0L; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptOrderVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptOrderVo.java index dac4aa9c..9e12c442 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptOrderVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptOrderVo.java @@ -36,7 +36,7 @@ public class HardCanteenUserApptOrderVo { @Schema(description = "订单状态协议编码") private String orderStatus; - /** 订单最近更新时间;已核销订单中作为核销时间展示。 */ + /** 核销时间兼容字段;优先来源于订单独立核销时间,历史空值回退到订单最近更新时间。 */ @Schema(description = "核销时间") private LocalDateTime updateTime; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptStatisticsSummaryVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptStatisticsSummaryVo.java new file mode 100644 index 00000000..0c85a200 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptStatisticsSummaryVo.java @@ -0,0 +1,53 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 用户预约统计的通用汇总数据。 + * + *

金额字段统一使用分;用户数量按“用户-用餐日期”记录累计,避免多日查询把同一用户重复隐藏。

+ */ +@Data +@Schema(description = "食堂用户预约统计汇总") +public class HardCanteenUserApptStatisticsSummaryVo { + + /** 当前统计范围内的用户-用餐日期记录数。 */ + @Schema(description = "用户日期记录数") + private Long userCount = 0L; + + /** 当前统计范围内去重后的用户数。 */ + @Schema(description = "去重用户数") + private Long uniqueUserCount = 0L; + + /** 有效预约记录的用户-用餐日期数。 */ + @Schema(description = "已订餐记录数") + private Long reservedUserCount = 0L; + + /** 没有有效预约记录的用户-用餐日期数。 */ + @Schema(description = "未订餐记录数") + private Long unreservedUserCount = 0L; + + /** 有效预约订单数。 */ + @Schema(description = "预约订单数") + private Long orderCount = 0L; + + /** 购物车快照中的菜品总份数。 */ + @Schema(description = "菜品总份数") + private Long dishCount = 0L; + + /** 有效预约订单商品总价,单位为分。 */ + @Schema(description = "预约总价,单位为分") + private Long totalPrice = 0L; + + /** 按餐段聚合的预约人数、订单数和菜品份数。 */ + @Schema(description = "餐段统计") + private List meals = new ArrayList<>(); + + /** 按餐段和菜品名称聚合的菜品份数。 */ + @Schema(description = "菜品统计") + private List dishes = new ArrayList<>(); +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptStatisticsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptStatisticsVo.java new file mode 100644 index 00000000..5cc19bec --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptStatisticsVo.java @@ -0,0 +1,23 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 用户预约统计响应,包含查询范围总计和逐日统计。 + */ +@Data +@Schema(description = "食堂用户预约统计") +public class HardCanteenUserApptStatisticsVo { + + /** 当前查询条件下所有用餐日期的汇总结果。 */ + @Schema(description = "查询范围总计") + private HardCanteenUserApptStatisticsSummaryVo total = new HardCanteenUserApptStatisticsSummaryVo(); + + /** 当前查询条件下按用餐日期升序排列的每日结果。 */ + @Schema(description = "每日统计") + private List daily = new ArrayList<>(); +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java index 0ba972bd..daa2fca5 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java @@ -9,6 +9,12 @@ import lombok.Data; @Data public class HardCarVo extends HardCarEntity { + /** 统计明细来源编码,来源于 HardSaleSourceEnum,不对应购物车持久化字段。 */ + private String saleSource; + + /** 统计明细来源名称,用于管理端展示,不对应购物车持久化字段。 */ + private String saleSourceName; + /** 超市商品 SPU ID,仅超市统计和展示场景赋值。 */ private Long spuId; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceImageVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceImageVo.java new file mode 100644 index 00000000..ee164957 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceImageVo.java @@ -0,0 +1,33 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEvidenceImageEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * PC 订单详情使用的订单证据图片,附带稳定编码对应的中文名称。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "订单识别证据图片详情") +public class HardOrderEvidenceImageVo extends HardOrderEvidenceImageEntity { + + /** + * 证据类型中文名称,未知历史编码时回退原编码。 + */ + @Schema(description = "证据类型名称") + private String evidenceTypeName; + + /** + * 图片角色中文名称,未知历史编码时回退原编码。 + */ + @Schema(description = "证据图片角色名称") + private String evidenceRoleName; + + /** + * 证据状态中文名称,未知历史编码时回退原编码。 + */ + @Schema(description = "证据状态名称") + private String statusName; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceResolveVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceResolveVo.java new file mode 100644 index 00000000..4c022089 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceResolveVo.java @@ -0,0 +1,34 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +/** + * 终端按本地订单号对账时返回的服务端订单与证据快照。 + */ +@Data +@Builder +@Schema(description = "订单证据对账结果") +public class HardOrderEvidenceResolveVo { + + /** + * 服务端订单主键。 + */ + @Schema(description = "服务端订单主键") + private Long orderId; + + /** + * 服务端订单状态,终端仅在现有支付流程确认成功后上传证据。 + */ + @Schema(description = "订单状态") + private String orderStatus; + + /** + * 当前服务端已登记的证据位置和状态,用于终端幂等跳过成功项。 + */ + @Schema(description = "证据列表") + private List evidenceImages; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceUploadResultVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceUploadResultVo.java new file mode 100644 index 00000000..43388162 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceUploadResultVo.java @@ -0,0 +1,50 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +/** + * 终端提交元数据、复制参考图或上传现场图后的幂等处理结果。 + */ +@Data +@Builder +@Schema(description = "订单证据处理结果") +public class HardOrderEvidenceUploadResultVo { + + /** + * 服务端证据记录主键。 + */ + @Schema(description = "证据记录主键") + private Long evidenceId; + + /** + * 服务端订单主键。 + */ + @Schema(description = "订单主键") + private Long orderId; + + /** + * 处理后的证据状态。 + */ + @Schema(description = "证据状态") + private String status; + + /** + * AVAILABLE 状态下的图片访问地址,其他状态允许为空。 + */ + @Schema(description = "图片访问地址") + private String imageUrl; + + /** + * 是否实际上传或复制了新文件;幂等命中时为 false。 + */ + @Schema(description = "是否新写入文件") + private Boolean uploaded; + + /** + * 面向终端同步日志的简短处理结果。 + */ + @Schema(description = "处理结果说明") + private String message; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java index 95cf42a0..ffe257dd 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java @@ -13,6 +13,12 @@ import java.util.List; @Data public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterface, BaseMchInterface, BaseHardInterface { + /** 统计明细来源编码;线下订单为 OFFLINE_PURCHASE,预约已核销订单为 RESERVATION_VERIFIED。 */ + private String saleSource; + + /** 统计明细来源名称,用于管理端明确区分支付和核销来源。 */ + private String saleSourceName; + /** * 用户相关 */ @@ -102,4 +108,9 @@ public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterfac * 当前访问者是否可以查看预约核销码。 */ private Boolean canViewReserveCode; + + /** + * 订单详情中的长期识别证据列表,按证据类型、角色和序号排序。 + */ + private List evidenceImages; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenDeptTotalExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenDeptTotalExcelVo.java new file mode 100644 index 00000000..44132e02 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenDeptTotalExcelVo.java @@ -0,0 +1,54 @@ +package com.bsd.pjcloud.hard.api.vo.order.excel; + +import com.alibaba.excel.annotation.ExcelProperty; +import com.alibaba.excel.annotation.format.NumberFormat; +import com.alibaba.excel.annotation.write.style.ColumnWidth; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 食堂菜品部门销售统计导出行,按日期、食堂、来源、部门和餐段拆分。 + */ +@Data +@ColumnWidth(20) +public class HardCanteenDeptTotalExcelVo { + + /** 业务发生日期;线下取支付日期,预约取核销日期。 */ + @ExcelProperty("日期") + private LocalDate statisticsDate; + + /** 当前用户商户权限范围内的食堂名称。 */ + @ExcelProperty("食堂") + private String merchantName; + + /** 销售来源中文名称,取值为线下购买或预约核销。 */ + @ExcelProperty("来源") + private String saleSourceName; + + /** 下单用户所属部门名称;无法解析时为空。 */ + @ExcelProperty("部门") + private String userDeptName; + + /** 餐段字典名称,来源于 daily_menu。 */ + @ExcelProperty("餐段") + private String hourTypeName; + + /** 当前部门餐段内去重后的交易用户数量,单位为人。 */ + @ExcelProperty("交易人数") + private Long payUserCount; + + /** 当前部门餐段内去重后的订单数量,单位为次。 */ + @ExcelProperty("交易次数") + private Long timeCount; + + /** 退款扣减口径下的有效商品数量,单位为份。 */ + @ExcelProperty("商品数量") + private Long payCount; + + /** 有效商品总金额,单位由分转换为元。 */ + @ExcelProperty("总价(元)") + @NumberFormat("#,##0.00") + private BigDecimal totalPrice; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java new file mode 100644 index 00000000..f83e31a4 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java @@ -0,0 +1,111 @@ +package com.bsd.pjcloud.hard.api.vo.order.excel; + +import com.alibaba.excel.annotation.ExcelIgnore; +import com.alibaba.excel.annotation.ExcelProperty; +import com.alibaba.excel.annotation.format.NumberFormat; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 食堂菜品销售统计自定义导出行。 + * + *

一个日期、餐段和来源口径按汇总行、商户部门行、商品明细行组织;来源口径包含筛选范围合计及各销售来源。 + * 商品统计在同一商户部门块内按名称合并套餐展开后的明细,不同层级只填写所属列,配合自定义合并处理器形成树形报表。

+ */ +@Data +public class HardCanteenProductApptExcelVo { + + /** 日期餐段汇总中的业务发生日期。 */ + @ExcelProperty({"日期餐段汇总", "统计日期"}) + private LocalDate summaryDate; + + /** 日期餐段汇总中的餐段编码或字典名称。 */ + @ExcelProperty({"日期餐段汇总", "餐段"}) + private String summaryHourType; + + /** 日期餐段汇总的来源口径,取值为筛选范围合计、线下购买或预约核销。 */ + @ExcelProperty({"日期餐段汇总", "来源口径"}) + private String summarySaleSourceName; + + /** 日期餐段汇总中的去重交易人数。 */ + @ExcelProperty({"日期餐段汇总", "交易人数"}) + private Long summaryPayUserCount; + + /** 日期餐段汇总中的交易次数。 */ + @ExcelProperty({"日期餐段汇总", "交易次数"}) + private Long summaryTimeCount; + + /** 日期餐段汇总中的商品总数量。 */ + @ExcelProperty({"日期餐段汇总", "商品数量"}) + private Long summaryPayCount; + + /** 日期餐段汇总中的总价,单位为元。 */ + @ExcelProperty({"日期餐段汇总", "总价"}) + @NumberFormat("#,##0.00") + private BigDecimal summaryTotalPrice; + + /** 商户部门统计中的食堂名称。 */ + @ExcelProperty({"商户部门统计", "商户"}) + private String merchantName; + + /** 商户部门统计中的用户部门名称。 */ + @ExcelProperty({"商户部门统计", "部门"}) + private String departmentName; + + /** 商户部门统计中的业务发生日期。 */ + @ExcelProperty({"商户部门统计", "统计日期"}) + private LocalDate merchantDate; + + /** 商户部门统计中的餐段名称。 */ + @ExcelProperty({"商户部门统计", "餐段"}) + private String merchantHourType; + + /** 商户部门统计中的去重交易人数。 */ + @ExcelProperty({"商户部门统计", "交易人数"}) + private Long merchantPayUserCount; + + /** 商户部门统计中的交易次数。 */ + @ExcelProperty({"商户部门统计", "交易次数"}) + private Long merchantTimeCount; + + /** 商户部门统计中的有效商品总数量。 */ + @ExcelProperty({"商户部门统计", "商品数量"}) + private Long merchantPayCount; + + /** 商户部门统计中的总价,单位为元。 */ + @ExcelProperty({"商户部门统计", "总价"}) + @NumberFormat("#,##0.00") + private BigDecimal merchantTotalPrice; + + /** 商品明细中的商品名称。 */ + @ExcelProperty({"商品明细", "商品"}) + private String productName; + + /** 商品明细中的商品数量。 */ + @ExcelProperty({"商品明细", "数量"}) + private Long productCount; + + /** 商品明细中的商品总价,单位为元。 */ + @ExcelProperty({"商品明细", "总价"}) + @NumberFormat("#,##0.00") + private BigDecimal productTotalPrice; + + /** 商品统计中的商品名称,同一商户部门内按规范化后的名称合并。 */ + @ExcelProperty({"商品统计", "商品名称"}) + private String statisticProductName; + + /** 商品统计中的商品总数量,预约按退款扣减,套餐按组成商品展开后的有效明细累计。 */ + @ExcelProperty({"商品统计", "商品总数量"}) + private Long statisticProductCount; + + /** 商品统计中的商品总价,单位为元,按退款扣减及套餐展开后的有效明细累计。 */ + @ExcelProperty({"商品统计", "商品总价格"}) + @NumberFormat("#,##0.00") + private BigDecimal statisticProductTotalPrice; + + /** 导出行层级,仅供合并处理器和测试识别,不写入 Excel。 */ + @ExcelIgnore + private String rowLevel; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductTotalExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductTotalExcelVo.java new file mode 100644 index 00000000..8716e2d0 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductTotalExcelVo.java @@ -0,0 +1,46 @@ +package com.bsd.pjcloud.hard.api.vo.order.excel; + +import com.alibaba.excel.annotation.ExcelProperty; +import com.alibaba.excel.annotation.format.NumberFormat; +import com.alibaba.excel.annotation.write.style.ColumnWidth; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 食堂菜品销售统计导出行,按统计日期、餐段和销售来源拆分。 + */ +@Data +@ColumnWidth(20) +public class HardCanteenProductTotalExcelVo { + + /** 业务发生日期;线下取支付日期,预约取核销日期。 */ + @ExcelProperty("日期") + private LocalDate statisticsDate; + + /** 餐段字典名称,来源于 daily_menu。 */ + @ExcelProperty("餐段") + private String hourTypeName; + + /** 销售来源中文名称,取值为线下购买或预约核销。 */ + @ExcelProperty("来源") + private String saleSourceName; + + /** 来源内去重后的交易用户数量,单位为人。 */ + @ExcelProperty("交易人数") + private Long payUserCount; + + /** 来源内去重后的订单数量,单位为次。 */ + @ExcelProperty("交易次数") + private Long timeCount; + + /** 退款扣减口径下的有效商品数量,单位为份。 */ + @ExcelProperty("商品数量") + private Long payCount; + + /** 有效商品总金额,单位由分转换为元。 */ + @ExcelProperty("总价(元)") + @NumberFormat("#,##0.00") + private BigDecimal totalPrice; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardOrderEvidenceImageController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardOrderEvidenceImageController.java new file mode 100644 index 00000000..4ec314d7 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardOrderEvidenceImageController.java @@ -0,0 +1,87 @@ +package com.bsd.pjcloud.hard.controller.hard; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceSubmitDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceUploadDto; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceResolveVo; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceUploadResultVo; +import com.bsd.pjcloud.hard.service.HardOrderEvidenceImageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +/** + * 收银终端订单识别证据接口,使用统一 Token、租户头和 DEVICE-ID 校验调用方身份。 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/hard-order-evidence") +@Tag(description = "hard-order-evidence", name = "订单识别证据") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class HardOrderEvidenceImageController { + + /** + * 订单识别证据业务服务。 + */ + private final HardOrderEvidenceImageService hardOrderEvidenceImageService; + + /** + * 对账终端订单并返回服务端证据状态。 + * + * @param hardId 当前绑定设备主键 + * @param androidOrderCode 终端本地订单号,预约核销证据允许为空 + * @param orderId 服务端订单主键,现场下单证据允许为空 + * @return 服务端订单和证据列表 + */ + @GetMapping("/ad/resolve") + @Operation(summary = "按终端订单号或服务端订单ID对账识别证据") + public R resolve(@RequestHeader("DEVICE-ID") Long hardId, + @RequestParam(required = false) String androidOrderCode, + @RequestParam(required = false) Long orderId) { + return R.ok(hardOrderEvidenceImageService.resolve(hardId, androidOrderCode, orderId)); + } + + /** + * 登记证据元数据、复制参考图或报告终端图片永久缺失。 + * + * @param hardId 当前绑定设备主键 + * @param dto 证据元数据和目标状态 + * @return 处理后的证据状态 + */ + @PostMapping("/ad/submit") + @Operation(summary = "提交订单识别证据元数据") + public R submit(@RequestHeader("DEVICE-ID") Long hardId, + @Valid @RequestBody HardOrderEvidenceSubmitDto dto) { + return R.ok(hardOrderEvidenceImageService.submit(hardId, dto)); + } + + /** + * 上传现场识别图片,服务端校验图片内容后写入订单专属文件目录。 + * + * @param hardId 当前绑定设备主键 + * @param dto 订单与识别元数据 + * @param file JPEG 或 PNG 现场图片 + * @return 幂等上传结果 + */ + @PostMapping(value = "/ad/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "上传订单现场识别图片") + public R upload(@RequestHeader("DEVICE-ID") Long hardId, + @Valid @ModelAttribute HardOrderEvidenceUploadDto dto, + @RequestPart("file") MultipartFile file) { + return R.ok(hardOrderEvidenceImageService.upload(hardId, dto, file)); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java index a66b061a..faecc1da 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java @@ -80,6 +80,35 @@ public class HardAutoRefundTaskController { return R.ok(taskService.pageTasks(page, dto)); } + /** + * 查询已到期且仍可整单退款的每日菜谱预约订单;失败或重试耗尽任务不混入候选。 + */ + @GetMapping("/expired-candidates") + @Operation(summary = "查询待过期预约退款订单") + @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_view')") + public R> expiredCandidates(@ParameterObject Page page, + @ParameterObject HardAutoRefundTaskPageDto dto) { + List scopedMchIds = listScopedMerchantIds(); + if (scopedMchIds.isEmpty()) { + Page empty = new Page<>(page.getCurrent(), page.getSize(), 0L); + empty.setRecords(List.of()); + return R.ok(empty); + } + dto.setMchIds(scopedMchIds); + return R.ok(taskService.pageExpiredCandidates(page, dto)); + } + + /** 管理端确认后逐单执行过期预约整单退款。 */ + @PostMapping("/{id}/expired-refund") + @Operation(summary = "过期预约订单退款") + @Idempotent(expireTime = 5, info = "正在退款,请勿重复操作") + @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')") + public R expiredRefund(@PathVariable Long id) { + validateTaskScope(id); + taskService.manualExpiredRefund(id, SecurityUtils.getUser().getId()); + return R.ok(true); + } + /** * 查询指定任务全部只读尝试日志。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java index 097bb40a..26048912 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java @@ -16,8 +16,12 @@ 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.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; @@ -51,11 +55,19 @@ import java.util.List; @SecurityRequirement(name = HttpHeaders.AUTHORIZATION) public class HardCanteenApptTotalController { + /** 无可见食堂时使用的不可命中商户主键,避免空集合被 SQL 当作“不筛选”而造成越权导出。 */ + private static final Long NO_VISIBLE_CANTEEN_ID = Long.MIN_VALUE; + /** * 食堂预约统计服务,负责菜品和用户维度的预约聚合。 */ private final HardCanteenApptTotalService canteenApptTotalService; + /** + * 食堂预约导出服务,负责异步任务、分批写入和文件上传,不让统计服务承担长耗时导出职责。 + */ + private final HardCanteenApptTotalExportService canteenApptTotalExportService; + /** * 通用食堂统计服务,复用部门聚合以及商户、部门信息补全能力。 */ @@ -170,6 +182,21 @@ public class HardCanteenApptTotalController { return R.ok(canteenApptTotalService.userReservationDetail(userId, apptUseDate)); } + /** + * 查询当前用户预约筛选条件对应的每日和范围总计。 + * + * @param query 用户、日期范围、食堂、餐段和状态筛选条件 + * @return 预约统计结果 + */ + @Operation(summary = "用户预约统计", description = "按日统计用户预约人数、餐段及菜品份数") + @ApiCryptoAes + @PostMapping("/user-reserv-statistics") + @PreAuthorize("@pms.hasPermission('hard_canteenUserApptTotal_page')") + public R userReservationStatistics( + @Valid @RequestBody HardCanteenUserApptQueryDto query) { + return R.ok(canteenApptTotalService.userReservationStatistics(query)); + } + /** * 提交用户每日预约异步导出任务。 * @@ -182,7 +209,7 @@ public class HardCanteenApptTotalController { @PostMapping("/user-reserv-export-task") public R submitUserReservationExport( @Valid @RequestBody HardCanteenUserApptQueryDto query) { - return R.ok(canteenApptTotalService.submitUserReservationExport(query), "用户预约导出任务已提交"); + return R.ok(canteenApptTotalExportService.submitUserReservationExport(query), "用户预约导出任务已提交"); } /** @@ -198,7 +225,70 @@ public class HardCanteenApptTotalController { @PostMapping("/user-reserv-export") public List userReservationExport( @Valid @RequestBody HardCanteenUserApptQueryDto query) { - return canteenApptTotalService.userReservationExport(query); + 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 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 productReservationExport(@RequestBody HardOrderDto query) { + applyCanteenMerchantScope(query); + return canteenApptTotalExportService.productReservationExport(query); + } + + /** + * 为后台菜品预约导出补充当前操作者可见的食堂范围。 + * + *

分页查询接口已有相同约束;导出任务在异步线程执行前必须冻结这份范围,避免未选择食堂时 + * 直接按空 {@code mchIds} 查询到当前权限之外的商户数据。

+ * + * @param query 菜品预约导出的请求条件;为空时保留给导出服务做参数校验 + */ + private void applyCanteenMerchantScope(HardOrderDto query) { + if (query == null) { + return; + } + MchManageDto mchManageDto = new MchManageDto(); + mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType())); + R> scopeResponse = remoteMchService.scopeList(mchManageDto); + List mchList = scopeResponse == null ? null : scopeResponse.getData(); + List 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 allowedMerchantIds = query.getMchIds().stream().filter(visibleMerchantIds::contains).toList(); + query.setMchIds(ObjectUtil.isEmpty(allowedMerchantIds) + ? List.of(NO_VISIBLE_CANTEEN_ID) : allowedMerchantIds); } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenDeptTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenDeptTotalController.java index 4df49ca7..0b509485 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenDeptTotalController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenDeptTotalController.java @@ -4,18 +4,20 @@ import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; +import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; -import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; -import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenDeptTotalExcelVo; import com.bsd.pjcloud.hard.service.HardCanteenTotalService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -44,8 +46,23 @@ public class HardCanteenDeptTotalController { PjcloudUser user = SecurityUtils.getUser(); dto.setTotalIsFindChildren(true); - dto.setApptIdOrApptRuleIdNotNull(true); return R.ok(canteenTotalService.listByDept(dto, user, dataScope)); } + /** + * 导出食堂部门销售统计,筛选和商户范围与部门统计主表一致。 + * + * @param dto 来源统计日期、来源、食堂、餐段和部门筛选 + * @return 按日期、食堂、来源、部门和餐段拆分的 Excel 行 + */ + @Operation(summary = "食堂菜品部门统计导出", description = "按日期、食堂、来源、部门和餐段导出销售统计") + @SysLog("食堂菜品部门统计导出") + @PreAuthorize("@pms.hasPermission('hard_canteenDeptTotal_export')") + @ResponseExcel(name = "食堂菜品部门统计") + @PostMapping("/export-dept") + public List exportDept(@RequestBody HardOrderDto dto) { + PjcloudUser user = SecurityUtils.getUser(); + return canteenTotalService.exportDept(dto, user, null); + } + } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java index 7c926661..d0ae0980 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java @@ -7,6 +7,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; +import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.common.security.annotation.Inner; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; @@ -21,6 +23,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByCanteenVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByClassifyVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductLikeTopVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductTotalExcelVo; import com.bsd.pjcloud.hard.service.HardCanteenTotalService; import com.bsd.pjcloud.wallet.api.dto.MchManageDto; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; @@ -32,6 +35,7 @@ import lombok.Data; import lombok.RequiredArgsConstructor; import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.HttpHeaders; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -138,9 +142,45 @@ public class HardCanteenTotalController { return R.ok(canteenTotalService.productTotal(page, dto, null)); } + /** + * 导出食堂菜品销售统计,筛选范围与菜品统计主表完全一致。 + * + * @param dto 来源统计日期、来源、食堂、餐段和部门筛选 + * @return 按日期、餐段和来源拆分的 Excel 行 + */ + @Operation(summary = "食堂菜品销售统计导出", description = "按日期、餐段和来源导出食堂菜品销售统计") + @SysLog("食堂菜品销售统计导出") + @PreAuthorize("@pms.hasPermission('hard_canteenProductTotal_export')") + @ResponseExcel(name = "食堂菜品销售统计") + @PostMapping("/export-product") + public List exportProduct(@RequestBody HardOrderDto dto) { + MchManageDto mchManageDto = new MchManageDto(); + mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType())); + List mchList = remoteMchService.scopeList(mchManageDto).getData(); + if (ObjectUtil.isNotEmpty(mchList) && ObjectUtil.isEmpty(dto.getMchIds())) { + dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList()); + } + return canteenTotalService.exportProduct(dto, null); + } + + /** + * 查询菜品销售明细;新版来源统计补充当前用户可见食堂范围,旧查询仍沿用原接口行为。 + * + * @param page 分页参数 + * @param dto 商品名称、统计日期、来源、食堂、餐段和部门筛选 + * @return 按商品聚合的销售明细分页结果 + */ @Operation(summary = "菜品出售统计", description = "菜品出售统计") @PostMapping("/proudct-query") public R> proudctQuery(@ParameterObject Page page, @RequestBody HardCarDto dto) { + if (dto.getStatisticsDateTimeList() != null && ObjectUtil.isEmpty(dto.getMchIds())) { + MchManageDto mchManageDto = new MchManageDto(); + mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType())); + List mchList = remoteMchService.scopeList(mchManageDto).getData(); + if (ObjectUtil.isNotEmpty(mchList)) { + dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList()); + } + } return R.ok(canteenTotalService.proudctQuery(page, dto)); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java index d3780447..b9936a98 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java @@ -538,6 +538,20 @@ public class HardOrderController { return R.ok(hardwareOrderService.handCancelCode(body)); } + /** + * 将工作人员误核销的线上预订订单恢复到待核销状态,沿用手动核销权限控制。 + * + * @param orderId 已核销的线上预订订单主键 + * @return 订单及预约二维码均恢复成功时返回 {@code true} + */ + @Operation(summary = "admin退回线上预订核销", description = "将已核销的线上预订订单退回到待核销状态") + @PutMapping("/reserve-verification-rollback/{orderId}") + @Idempotent(expireTime = 1, key = "'/hard-order/reserve-verification-rollback' + #orderId ", info = "正在退回核销状态,请勿重复操作") + @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_cancel')") + public R rollbackReserveVerification(@PathVariable Long orderId) { + return R.ok(hardwareOrderService.rollbackReserveVerification(orderId)); + } + @Operation(summary = "分页查询", description = "分页查询") @PostMapping("/page") @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')") @@ -621,7 +635,7 @@ public class HardOrderController { @GetMapping("/{id}") @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')") public R getById(@PathVariable("id") Long id) { - return R.ok(hardwareOrderService.join(id)); + return R.ok(hardwareOrderService.joinWithEvidence(id)); } @Operation(summary = "离线订单手动支付", description = "离线订单手动支付") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java new file mode 100644 index 00000000..fa9811af --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java @@ -0,0 +1,326 @@ +package com.bsd.pjcloud.hard.excel; + +import com.alibaba.excel.write.handler.RowWriteHandler; +import com.alibaba.excel.write.handler.WorkbookWriteHandler; +import com.alibaba.excel.write.metadata.holder.WriteWorkbookHolder; +import com.alibaba.excel.write.metadata.holder.WriteSheetHolder; +import com.alibaba.excel.write.metadata.holder.WriteTableHolder; +import org.apache.poi.ss.usermodel.BorderStyle; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.VerticalAlignment; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.xssf.usermodel.XSSFCellStyle; +import org.apache.poi.xssf.usermodel.XSSFColor; + +import java.util.HashMap; +import java.util.Map; + +/** + * 食堂菜品销售统计三级导出合并处理器。 + * + *

数据行通过各层级专属列识别:日期餐段来源行填写第 0 列,商户部门行填写第 7 列,商品行填写第 15 列; + * 第 18 至 20 列展示同一商户部门内按名称合并后的商品统计。 + * Easy Excel 使用流式工作簿,较早的数据行会在关闭工作簿前刷出内存,因此必须在逐行写出时记录分组边界并及时合并。

+ */ +public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler, WorkbookWriteHandler { + + /** 日期餐段汇总列。 */ + private static final int[] SUMMARY_COLUMNS = {0, 1, 2, 3, 4, 5, 6}; + + /** 商户部门统计列。 */ + private static final int[] MERCHANT_COLUMNS = {7, 8, 9, 10, 11, 12, 13, 14}; + + /** Easy Excel 双层表头占用两行,数据从第三行开始。 */ + private static final int DATA_START_ROW = 2; + + /** 导出列总数,用于逐行应用居中样式。 */ + private static final int COLUMN_COUNT = 21; + + /** 表头字体大小,单位为磅;使用紧凑字号降低双层表头占用空间。 */ + private static final short HEAD_FONT_SIZE = 10; + + /** 日期分组边框线型,参考模板使用中等线宽突出每天的数据边界。 */ + 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} + }; + + /** + * 各列宽度,单位为 Excel 字符宽度;常规统计列保持紧凑,商品名称列单独加宽以展示更多名称内容。 + */ + private static final int[] COLUMN_WIDTHS = { + 12, 11, 16, 10, 10, 14, 11, 14, 14, 12, 11, 10, 10, 14, 11, 32, 9, 11, 32, 14, 12 + }; + + /** 原样式索引到居中样式的缓存,避免每个数据单元格重复创建样式。 */ + private final Map centeredStyles = new HashMap<>(); + + /** 原样式、日期颜色和边框位置到彩色边框样式的缓存,避免重复创建工作簿样式。 */ + private final Map dateGroupBorderStyles = new HashMap<>(); + + /** 当前日期餐段分组的起始行,使用 POI 从零开始的行号。 */ + private int summaryGroupStart = -1; + + /** 当前商户部门分组的起始行,使用 POI 从零开始的行号。 */ + private int merchantGroupStart = -1; + + /** 最近写出的数据行号,用于工作簿结束时收尾最后一组。 */ + private int lastDataRow = -1; + + /** 当前预约日期分组序号,同一天的所有餐段共用一个序号和边框颜色。 */ + private int dateGroupIndex = -1; + + /** 当前预约日期的单元格值标识,用于判断新餐段是否仍属于同一天。 */ + private String currentDateKey; + + /** 当前导出工作表;本处理器只注册到菜品预约导出的单个工作表。 */ + private Sheet currentSheet; + + /** + * 数据行完成写入后立即处理样式和分组边界,避免该行被流式工作簿刷出内存后无法再次读取。 + * + *

Easy Excel 已为金额列设置数字格式,居中时必须从原样式复制再覆盖对齐属性, + * 不能直接替换成全新的样式,否则金额会失去两位小数格式。

+ * + * @param writeSheetHolder 当前工作表持有者 + * @param writeTableHolder 当前表格持有者,本导出未使用独立表格 + * @param row 刚完成写入且尚在内存中的行 + * @param relativeRowIndex 当前批次中的相对行号 + * @param isHead 是否为表头行 + */ + @Override + public void afterRowDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row, + Integer relativeRowIndex, Boolean isHead) { + if (row == null) { + return; + } + if (Boolean.TRUE.equals(isHead)) { + applyCompactHeadFont(row, writeSheetHolder.getParentWriteWorkbookHolder()); + return; + } + if (row.getRowNum() < DATA_START_ROW) { + return; + } + currentSheet = writeSheetHolder.getSheet(); + applyCenteredStyle(row, writeSheetHolder.getParentWriteWorkbookHolder()); + int rowIndex = row.getRowNum(); + boolean summaryRow = hasValue(row, 0); + boolean merchantRow = hasValue(row, 7); + boolean newDateGroup = false; + if (summaryRow) { + String summaryDateKey = getCellValueKey(row, 0); + newDateGroup = currentDateKey == null || !currentDateKey.equals(summaryDateKey); + if (newDateGroup) { + Row previousDateLastRow = currentSheet.getRow(rowIndex - 1); + applyDateGroupBorder(previousDateLastRow, writeSheetHolder.getParentWriteWorkbookHolder(), + dateGroupIndex, false, true); + currentDateKey = summaryDateKey; + dateGroupIndex++; + } + mergeColumns(currentSheet, summaryGroupStart, rowIndex - 1, SUMMARY_COLUMNS); + summaryGroupStart = rowIndex; + mergeColumns(currentSheet, merchantGroupStart, rowIndex - 1, MERCHANT_COLUMNS); + merchantGroupStart = -1; + } + applyDateGroupBorder(row, writeSheetHolder.getParentWriteWorkbookHolder(), dateGroupIndex, + newDateGroup, false); + if (merchantRow) { + mergeColumns(currentSheet, merchantGroupStart, rowIndex - 1, MERCHANT_COLUMNS); + merchantGroupStart = rowIndex; + } + lastDataRow = rowIndex; + } + + /** + * 工作簿关闭前收尾最后一个日期餐段和商户部门分组,并统一设置列宽。 + * + * @param holder Easy Excel 当前工作簿持有者 + */ + @Override + public void afterWorkbookDispose(WriteWorkbookHolder holder) { + if (currentSheet == null || lastDataRow < DATA_START_ROW) { + return; + } + mergeColumns(currentSheet, summaryGroupStart, lastDataRow, SUMMARY_COLUMNS); + mergeColumns(currentSheet, merchantGroupStart, lastDataRow, MERCHANT_COLUMNS); + Row lastRow = currentSheet.getRow(lastDataRow); + if (lastRow != null) { + applyDateGroupBorder(lastRow, holder, dateGroupIndex, false, true); + } + for (int column = 0; column < COLUMN_WIDTHS.length; column++) { + currentSheet.setColumnWidth(column, COLUMN_WIDTHS[column] * 256); + } + } + + /** + * 在当前行仍处于流式窗口时复制原数字格式并应用水平、垂直居中。 + * + * @param row 当前数据行 + * @param holder 当前工作簿持有者,用于创建可复用样式 + */ + private void applyCenteredStyle(Row row, WriteWorkbookHolder holder) { + for (int column = 0; column < COLUMN_COUNT; column++) { + Cell cell = row.getCell(column); + if (cell == null) { + continue; + } + CellStyle originalStyle = cell.getCellStyle(); + short styleIndex = originalStyle.getIndex(); + CellStyle centeredStyle = centeredStyles.computeIfAbsent(styleIndex, key -> { + CellStyle copiedStyle = holder.getWorkbook().createCellStyle(); + copiedStyle.cloneStyleFrom(originalStyle); + copiedStyle.setAlignment(HorizontalAlignment.CENTER); + copiedStyle.setVerticalAlignment(VerticalAlignment.CENTER); + return copiedStyle; + }); + cell.setCellStyle(centeredStyle); + } + } + + /** + * 缩小当前表头行使用的字体;只调整字号并保留 Easy Excel 生成的字体名称、颜色和加粗属性。 + * + * @param row 当前表头行 + * @param holder 当前工作簿持有者,用于定位表头样式引用的字体 + */ + private void applyCompactHeadFont(Row row, WriteWorkbookHolder holder) { + for (int column = 0; column < COLUMN_COUNT; column++) { + Cell cell = row.getCell(column); + if (cell == null) { + continue; + } + Font font = holder.getWorkbook().getFontAt(cell.getCellStyle().getFontIndex()); + font.setFontHeightInPoints(HEAD_FONT_SIZE); + } + } + + /** + * 为当前预约日期设置整组外框:当天首个餐段首行加顶部边框,当天所有行加左右边框,末行加底部边框。 + * + *

流式工作簿会刷出较早的数据行,分组结束时无法可靠回读整组行,因此左右边框必须随行写出; + * 新日期开始时,最近写出的上一天末行仍在流式窗口内,可补齐上一天的本色底边。

+ * + * @param row 当前数据行 + * @param holder 当前工作簿持有者,用于创建彩色边框样式 + * @param groupIndex 当前预约日期分组序号,未识别到日期时为负数 + * @param topBorder 是否为当天首行并设置顶部边框 + * @param bottomBorder 是否为当天末行并设置底部边框 + */ + private void applyDateGroupBorder(Row row, WriteWorkbookHolder holder, int groupIndex, boolean topBorder, + boolean bottomBorder) { + if (row == null || groupIndex < 0) { + return; + } + int colorIndex = groupIndex % DATE_GROUP_BORDER_COLORS.length; + for (int column = 0; column < COLUMN_COUNT; column++) { + Cell cell = row.getCell(column); + if (cell == null) { + continue; + } + boolean leftBorder = column == 0; + boolean rightBorder = column == COLUMN_COUNT - 1; + if (!leftBorder && !rightBorder && !topBorder && !bottomBorder) { + continue; + } + CellStyle borderedStyle = getDateGroupBorderStyle(cell.getCellStyle(), holder, colorIndex, + leftBorder, rightBorder, topBorder, bottomBorder); + cell.setCellStyle(borderedStyle); + } + } + + /** + * 创建或复用保留原有数字格式、对齐方式的彩色边框样式。 + * + * @param originalStyle 当前单元格原样式 + * @param holder 当前工作簿持有者 + * @param colorIndex 日期边框颜色索引 + * @param leftBorder 是否设置左边框 + * @param rightBorder 是否设置右边框 + * @param topBorder 是否设置上边框 + * @param bottomBorder 是否设置下边框 + * @return 应用分组边框后的样式 + */ + private CellStyle getDateGroupBorderStyle(CellStyle originalStyle, WriteWorkbookHolder holder, int colorIndex, + boolean leftBorder, boolean rightBorder, boolean topBorder, + boolean bottomBorder) { + String cacheKey = originalStyle.getIndex() + ":" + colorIndex + ":" + leftBorder + rightBorder + + topBorder + bottomBorder; + CellStyle cachedStyle = dateGroupBorderStyles.get(cacheKey); + if (cachedStyle != null) { + return cachedStyle; + } + CellStyle copiedStyle = holder.getWorkbook().createCellStyle(); + copiedStyle.cloneStyleFrom(originalStyle); + if (!(copiedStyle instanceof XSSFCellStyle xssfStyle)) { + throw new IllegalStateException("菜品销售统计导出仅支持 XLSX 彩色边框样式"); + } + XSSFColor color = new XSSFColor(DATE_GROUP_BORDER_COLORS[colorIndex]); + if (leftBorder) { + xssfStyle.setBorderLeft(DATE_GROUP_BORDER_STYLE); + xssfStyle.setLeftBorderColor(color); + } + if (rightBorder) { + xssfStyle.setBorderRight(DATE_GROUP_BORDER_STYLE); + xssfStyle.setRightBorderColor(color); + } + if (topBorder) { + xssfStyle.setBorderTop(DATE_GROUP_BORDER_STYLE); + xssfStyle.setTopBorderColor(color); + } + if (bottomBorder) { + xssfStyle.setBorderBottom(DATE_GROUP_BORDER_STYLE); + xssfStyle.setBottomBorderColor(color); + } + dateGroupBorderStyles.put(cacheKey, copiedStyle); + return copiedStyle; + } + + /** + * 获取日期汇总单元格的稳定值标识;同一预约日期的不同餐段会得到相同标识。 + * + * @param row 当前日期餐段汇总行 + * @param columnIndex 预约日期所在列索引 + * @return 用于判断日期是否变化的非空值标识 + */ + private String getCellValueKey(Row row, int columnIndex) { + Cell cell = row.getCell(columnIndex); + return cell == null ? "" : cell.toString().trim(); + } + + /** + * 按列范围添加合并单元格;单行或尚未开始的分组无需创建合并区域。 + * + * @param sheet 当前导出工作表 + * @param firstRow 分组起始行,使用 POI 从零开始的行号 + * @param lastRow 分组结束行,使用 POI 从零开始的行号 + * @param columns 需要纵向合并的列索引 + */ + private void mergeColumns(Sheet sheet, int firstRow, int lastRow, int[] columns) { + if (sheet == null || firstRow < DATA_START_ROW || firstRow >= lastRow) { + return; + } + for (int column : columns) { + sheet.addMergedRegion(new CellRangeAddress(firstRow, lastRow, column, column)); + } + } + + /** + * 判断当前尚在内存中的行是否在指定列包含非空值。 + * + * @param row 当前数据行 + * @param columnIndex 用于识别行层级的列索引 + * @return 指定单元格存在且包含非空文本时返回 {@code true} + */ + private boolean hasValue(Row row, int columnIndex) { + Cell cell = row.getCell(columnIndex); + return cell != null && !cell.toString().trim().isEmpty(); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java index 41ea25c2..911c3d71 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java @@ -6,6 +6,9 @@ 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 org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; /** * 自动退款任务 Mapper,任务执行通过主键行锁串行化多实例竞争。 @@ -39,6 +42,7 @@ public interface HardAutoRefundTaskMapper extends PjcloudBaseMapper pageExpiredCandidates(Page page, + @Param("dto") HardAutoRefundTaskPageDto dto, + @Param("now") LocalDateTime now); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java index 40f4ac68..ef4e2303 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java @@ -12,6 +12,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity; import com.bsd.pjcloud.hard.api.vo.order.HardCarTotalVo; import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord; 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; @@ -35,10 +36,74 @@ public interface HardCarMapper extends PjcloudBaseMapper { Page productTotal(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope); + /** + * 按业务发生日期、餐段和来源分页统计菜品销售。 + * + * @param page 分页参数 + * @param dto 统计日期、来源、食堂、餐段和部门筛选 + * @param dataScope 订单所属部门数据范围;为空时不附加范围条件 + * @return 来源独立分组的菜品销售统计页 + */ + Page productTotalStats(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope); + + /** + * 查询菜品多维统计行且不应用数据库分页。 + * + *

同一次查询通过 ROLLUP 返回当日合计、餐段合计和餐段来源三层数据,保证交易人数在每个层级独立去重; + * 服务层按完整自然日分页,导出仅选择餐段来源层。

+ * + * @param dto 统计日期、来源、食堂、餐段和部门筛选 + * @param dataScope 订单所属部门数据范围;为空时不附加范围条件 + * @return 按日期、餐段和来源层级排序的多维统计行 + */ + List listProductTotalStats(@Param("dto") HardOrderDto dto, DataScope dataScope); + + /** + * 按当前主表行口径查询部门汇总,仅供新版菜品统计展开行使用。 + * + * @param dto 单日、餐段、来源、食堂和用户部门筛选 + * @param dataScope 订单所属部门数据范围;为空时不附加范围条件 + * @return 按用户部门拆分且在当前来源范围内独立去重的销售统计行 + */ + List productTotalByDeptStats(@Param("dto") HardOrderDto dto, DataScope dataScope); + Page productTotalByApptUseDate(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope); + /** + * 查询菜品销售三级导出的日期餐段汇总及来源拆分。 + * + * @param dto 统计日期、来源、餐段、食堂和部门筛选条件 + * @return 每个日期餐段的跨来源合计及各来源统计,交易人数在当前层级独立去重 + */ + List listSalesExportSummary(@Param("dto") HardOrderDto dto); + + /** + * 查询菜品销售三级导出的商户部门汇总及来源拆分。 + * + * @param dto 统计日期、来源、餐段、食堂和部门筛选条件 + * @return 每个日期餐段下商户部门的跨来源合计及各来源统计 + */ + List listSalesExportMerchant(@Param("dto") HardOrderDto dto); + + /** + * 查询菜品销售三级导出的商品汇总及来源拆分。 + * + * @param dto 统计日期、来源、餐段、食堂和部门筛选条件 + * @return 每个商户部门块下商品的跨来源合计及各来源统计 + */ + List listSalesExportProduct(@Param("dto") HardOrderDto dto); + Page proudctQuery(Page page, @Param("dto") HardCarDto dto); + /** + * 按来源和业务发生时间分页查询菜品汇总明细。 + * + * @param page 分页参数 + * @param dto 单日、来源、食堂、餐段、部门和商品名称筛选 + * @return 按商品聚合的来源明细页 + */ + Page proudctQueryStats(Page page, @Param("dto") HardCarDto dto); + /** * 菜品出售商户统计 */ @@ -46,8 +111,41 @@ public interface HardCarMapper extends PjcloudBaseMapper { List listByDept(@Param("dto") HardOrderDto dto, DataScope dataScope); + /** + * 按日期和食堂查询部门统计合计行及其来源拆分行。 + * + * @param dto 统计日期、来源、食堂、餐段和用户部门筛选 + * @param dataScope 订单所属部门数据范围;为空时不附加范围条件 + * @return 日期食堂合计行和日期食堂来源行,服务层据此装配主从层级 + */ + List listByDeptStats(@Param("dto") HardOrderDto dto, DataScope dataScope); + List listByHourType(@Param("dto") HardOrderDto dto); + /** + * 按日期、部门和餐段查询展开合计行及其来源拆分行。 + * + * @param dto 单日、食堂、来源、餐段和用户部门筛选 + * @return 部门餐段合计行和部门餐段来源行,服务层据此装配主从层级 + */ + List listByHourTypeStats(@Param("dto") HardOrderDto dto); + + /** + * 按预约订单父购物车主键批量读取商品快照,供预约统计计算真实菜品份数。 + * + * @param parentCarIds 预约订单关联的父购物车主键集合 + * @return 未删除的购物车商品快照;传入空集合时返回空列表 + */ + default List listByParentCarIds(List parentCarIds) { + if (parentCarIds == null || parentCarIds.isEmpty()) { + return List.of(); + } + return selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .in(HardCarEntity::getParentCarId, parentCarIds) + .orderByAsc(HardCarEntity::getParentCarId) + .orderByAsc(HardCarEntity::getId)); + } + /** * 菜品销量按照分类查询 */ @@ -62,4 +160,4 @@ public interface HardCarMapper extends PjcloudBaseMapper { * 个人菜品喜好排行 */ List productLikeTop(@Param("dto") HardAppProductLikeTopDto dto); -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderEvidenceImageMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderEvidenceImageMapper.java new file mode 100644 index 00000000..c00bf28f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderEvidenceImageMapper.java @@ -0,0 +1,47 @@ +package com.bsd.pjcloud.hard.mapper.order; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEvidenceImageEntity; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * 订单识别证据 Mapper,所有查询由租户拦截器自动限定当前租户。 + */ +@Mapper +public interface HardOrderEvidenceImageMapper extends PjcloudBaseMapper { + + /** + * 按订单读取未逻辑删除的证据,稳定按类型、角色、序号和主键排序。 + * + * @param orderId 服务端订单主键 + * @return 当前租户下的订单证据列表 + */ + default List listByOrderId(Long orderId) { + return selectList(new LambdaQueryWrapper() + .eq(HardOrderEvidenceImageEntity::getOrderId, orderId) + .orderByAsc(HardOrderEvidenceImageEntity::getEvidenceType) + .orderByAsc(HardOrderEvidenceImageEntity::getEvidenceRole) + .orderByAsc(HardOrderEvidenceImageEntity::getSequenceNo) + .orderByAsc(HardOrderEvidenceImageEntity::getId)); + } + + /** + * 查询订单中的唯一证据位置,用于上传幂等和内容冲突检查。 + * + * @param orderId 服务端订单主键 + * @param evidenceType 证据类型稳定编码 + * @param evidenceRole 图片角色稳定编码 + * @param sequenceNo 同角色图片序号 + * @return 已存在的证据;不存在时返回 {@code null} + */ + default HardOrderEvidenceImageEntity findPosition(Long orderId, String evidenceType, String evidenceRole, Integer sequenceNo) { + return selectOne(new LambdaQueryWrapper() + .eq(HardOrderEvidenceImageEntity::getOrderId, orderId) + .eq(HardOrderEvidenceImageEntity::getEvidenceType, evidenceType) + .eq(HardOrderEvidenceImageEntity::getEvidenceRole, evidenceRole) + .eq(HardOrderEvidenceImageEntity::getSequenceNo, sequenceNo)); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java index c87698d7..f7e54249 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java @@ -130,8 +130,43 @@ public interface HardOrderMapper extends PjcloudBaseMapper { return selectListByScope(commonWrapper(dto), DataScope.of()); } + /** + * 按订单筛选条件和数据范围分页查询订单详情,并保持创建时间或更新时间倒序。 + * + * @param page 分页参数;App 可见性查询会由服务层指定对应的轻量统计语句 + * @param dto 订单、商户、状态、时间及 App 可见范围筛选条件 + * @param dataScope 后台数据范围;App 查询传 {@code null} + * @return 当前页订单详情及符合相同筛选条件的总数 + */ Page scopePageList(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope); + /** + * 先按 App 可见范围和全部筛选条件分页读取订单主键,避免展示表关联参与全量排序。 + * + * @param page 主键分页参数,排序规则与订单列表保持一致 + * @param dto 订单、商户、状态、时间及 App 可见范围筛选条件 + * @param dataScope 保留的数据范围参数;当前 App 调用传 {@code null} + * @return 当前页有序订单主键及符合条件的总数 + */ + Page appVisibilityPageIds(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope); + + /** + * 按已分页的订单主键批量读取展示详情,仅关联当前页设备、最新售后单和付款人。 + * + * @param orderIds 当前页订单主键集合,调用方保证非空且已完成租户和可见范围过滤 + * @return 当前租户内存在的订单详情;SQL 不承诺返回顺序,由服务层按主键页顺序重排 + */ + List listAppPageDetails(@Param("orderIds") List orderIds); + + /** + * 统计 App 当前访问者可见的订单数量,不执行列表展示字段所需的多表关联。 + * + * @param dto 订单基础筛选、设备类型、最新售后单号及 App 可见范围条件 + * @param dataScope 保留的数据范围参数;当前 App 调用传 {@code null} + * @return 当前租户内符合全部筛选条件的未删除订单数量 + */ + Long appVisibilityCount(@Param("dto") HardOrderDto dto, DataScope dataScope); + List scopeList(@Param("dto") HardOrderDto dto, DataScope dataScope); /** diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java index 50431a38..90a46764 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java @@ -72,6 +72,33 @@ public interface HardAfterSaleService extends IService { */ void completeAutoRefund(Long afterSaleId, Long autoRefundConfigId); + /** + * 按过期预约任务创建并完成每日菜谱整单系统退款;任务快照决定是否恢复库存。 + * + * @param task 过期预约任务,afterSaleId 为空时由本方法创建系统售后 + */ + Long completeExpiredReserveRefund(com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity task); + + /** + * 完成过期预约整单退款,并允许调用方声明是否需要校验过期自动退款开关。 + * 管理端逐单业务退款即使商户关闭自动开关也必须可用,因此该路径不执行自动开关校验; + * 调度器和自动重试路径必须传入 {@code true},继续由 Wallet 做最终开关校验。 + * + * @param task 过期预约退款任务,afterSaleId 为空时由本方法创建系统售后 + * @param enforceAutoSwitch 是否按自动任务场景校验当前过期退款开关 + * @return 完成或复用的系统售后单 ID + */ + Long completeExpiredReserveRefund(com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity task, + boolean enforceAutoSwitch); + + /** + * 在资金退款前独立提交过期预约系统售后单及商品快照,确保跨服务退款成功后本地回滚仍可恢复。 + * + * @param task 过期预约任务,必须尚未关联售后单 + * @return 新建或已存在的系统售后单 ID + */ + Long prepareExpiredReserveRefund(com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity task); + /** * 同意售后 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java index 4d5f3bbc..fa8a34ee 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java @@ -39,8 +39,9 @@ public interface HardApptReservationService { * @param apptDate 实际用餐日期 * @param apptRuleId 周期预约规则 ID;动态模式为空 * @param eligibilityDeadline 当前已校验预约窗口的截止时间,使用服务器现有预约时间口径 + * @param usageEndDateTime 实际用餐餐段的结束时间;无法从有效餐段唯一解析时为空,禁止用于过期退款 */ record ReservationValidation(HardApptEntity appt, String hourType, LocalDate apptDate, Long apptRuleId, - LocalDateTime eligibilityDeadline) { + LocalDateTime eligibilityDeadline, LocalDateTime usageEndDateTime) { } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java index c88ac2c1..212fb749 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java @@ -27,6 +27,14 @@ public interface HardAutoRefundTaskService extends IService pageTasks(Page page, HardAutoRefundTaskPageDto dto); + /** 查询已到期且仍可整单退款的每日菜谱预约候选任务。 */ + Page pageExpiredCandidates(Page page, HardAutoRefundTaskPageDto dto); + /** * 查询任务的只读尝试日志,按尝试序号升序返回。 * @@ -68,6 +79,14 @@ public interface HardAutoRefundTaskService extends IService { */ void doneBroadcast(HardOrderEntity orderdetail, UserInfo userInfo); + /** + * 将误核销预约订单的取餐广播恢复为待处理,并通知当前商户的取餐设备重新展示。 + * + * @param orderdetail 已由订单服务校验为线上预订且正在回退的订单 + */ + void rollbackDoneBroadcast(HardOrderEntity orderdetail); + /** * android完成一轮广播 */ void androidDoneBroadcast(HardAdBroadcastDto dto); -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalExportService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalExportService.java new file mode 100644 index 00000000..53172794 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalExportService.java @@ -0,0 +1,50 @@ +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; + +/** + * 食堂预约统计导出服务。 + * + *

导出属于独立的长耗时任务边界,负责异步任务、分批读取、Easy Excel 写入和文件中心上传; + * 普通统计服务只负责页面查询和统计,不持有导出任务状态。

+ */ +public interface HardCanteenApptTotalExportService { + + /** + * 提交用户预约异步导出任务。 + * + * @param query 用户预约日期、部门、食堂、餐段和状态筛选条件 + * @return 可供前端轮询的初始任务进度 + */ + AsyncTaskProgressVO submitUserReservationExport(HardCanteenUserApptQueryDto query); + + /** + * 兼容旧同步用户预约导出接口。 + * + * @param query 用户预约筛选条件 + * @return 用户预约导出行 + */ + List userReservationExport(HardCanteenUserApptQueryDto query); + + /** + * 提交菜品预约异步导出任务。 + * + * @param query 菜品预约统计筛选条件 + * @return 可供前端轮询的初始任务进度 + */ + AsyncTaskProgressVO submitProductReservationExport(HardOrderDto query); + + /** + * 查询菜品预约自定义 Excel 行,供同步下载和异步写入复用。 + * + * @param query 菜品预约统计筛选条件 + * @return 按日期餐段、商户部门、商品三级结构排列的导出行 + */ + List productReservationExport(HardOrderDto query); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalService.java index a463b076..61eba838 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalService.java @@ -1,14 +1,13 @@ 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.hard.api.dto.order.HardCanteenUserApptQueryDto; 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.HardCanteenUserApptOrderVo; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptVo; -import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsVo; import java.time.LocalDate; import java.util.List; @@ -47,18 +46,11 @@ public interface HardCanteenApptTotalService { List userReservationDetail(Long userId, LocalDate apptUseDate); /** - * 提交用户每日预约异步导出任务,后台按权限用户分批查询并流式写入 Excel。 + * 按当前用户预约查询条件统计每日和查询范围内的预约人数、餐段及菜品。 * - * @param query 用户、日期范围和预约状态筛选条件 - * @return 供前端轮询的初始任务进度 + * @param query 用户、日期范围、食堂、餐段和状态筛选条件 + * @return 按日和总计组织的预约统计结果 */ - AsyncTaskProgressVO submitUserReservationExport(HardCanteenUserApptQueryDto query); + HardCanteenUserApptStatisticsVo userReservationStatistics(HardCanteenUserApptQueryDto query); - /** - * 查询并转换用户预约导出数据,包含没有预约订单的权限范围用户。 - * - * @param query 用户、日期范围和预约状态筛选条件 - * @return 用户和日期维度的 Excel 导出行,最后一行为餐段及当日金额合计 - */ - List userReservationExport(HardCanteenUserApptQueryDto query); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java index 7dec6c5f..7502b74e 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java @@ -15,6 +15,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByCanteenVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByClassifyVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductLikeTopVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo; import java.util.List; import java.util.Map; @@ -42,7 +43,12 @@ public interface HardCanteenTotalService { List productTotalByDept(Page page, HardOrderDto dto, DataScope dataScope); /** - * 食堂菜品购买统计 + * 分页查询食堂菜品购买统计。 + * + * @param page 分页参数;来源统计模式下每页数量表示自然日数量 + * @param dto 统计日期、来源、食堂、餐段和部门筛选 + * @param dataScope 订单部门数据范围;为空时不追加数据范围条件 + * @return 当日合计和餐段合计行,来源统计模式下每行包含对应来源明细 */ Page productTotal(Page page, HardOrderDto dto, DataScope dataScope); @@ -60,4 +66,16 @@ public interface HardCanteenTotalService { * 食堂菜品购买部门统计 */ List listByDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope); + + /** + * 按页面筛选条件导出统一的菜品销售三级统计。 + * + *

每个日期餐段同时生成当前来源范围的合计块以及各来源块,每个块继续按商户部门和商品展开; + * 菜品统计页与部门统计页共用该方法,避免两套导出口径再次分叉。

+ * + * @param dto 统计日期、来源、餐段、食堂和部门筛选条件 + * @param dataScope 部门数据权限;为空时沿用控制器既有范围 + * @return 日期餐段、商户部门、商品三级排列的导出行 + */ + List exportSales(HardOrderDto dto, DataScope dataScope); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java index fe8e1f9f..356b7b78 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java @@ -25,6 +25,14 @@ public interface HardCarService extends IService { List getByParentCarId(Long parentCarId); + /** + * 按父购物车主键批量查询未删除的购物车商品快照,供订单分页结果一次性装配商品列表。 + * + * @param parentCarIds 父购物车主键集合,空集合不发起数据库查询 + * @return 按父购物车主键和商品主键排序的商品快照 + */ + List getByParentCarIds(List parentCarIds); + List mchDetail(Long merchantId, Long userId, HardTypeEnum hardType); Integer computerCarTotalPrice(@NotNull HardTypeEnum sysType, @NotNull HardOrderTypeEnum orderType, @NotEmpty List list); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderEvidenceImageService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderEvidenceImageService.java new file mode 100644 index 00000000..2312cabb --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderEvidenceImageService.java @@ -0,0 +1,66 @@ +package com.bsd.pjcloud.hard.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceMetadataDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceSubmitDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceUploadDto; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEvidenceImageEntity; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceImageVo; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceResolveVo; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceUploadResultVo; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * 订单识别证据服务,负责设备与订单校验、幂等占位、现场图上传和参考图复制。 + */ +public interface HardOrderEvidenceImageService extends IService { + + /** + * 按终端订单号或服务端订单 ID 对账订单和已登记证据。 + * + * @param hardId 请求头中的当前设备主键 + * @param androidOrderCode 终端本地稳定订单号,预约核销证据允许为空 + * @param orderId 服务端订单主键,现场下单证据允许为空 + * @return 服务端订单及证据状态 + */ + HardOrderEvidenceResolveVo resolve(Long hardId, String androidOrderCode, Long orderId); + + /** + * 提交证据元数据、触发参考图复制或报告永久缺失。 + * + * @param hardId 请求头中的当前设备主键 + * @param dto 证据位置、识别快照和目标状态 + * @return 幂等处理结果 + */ + HardOrderEvidenceUploadResultVo submit(Long hardId, HardOrderEvidenceSubmitDto dto); + + /** + * 上传终端现场证据图片,实际文件内容会与终端哈希和尺寸交叉校验。 + * + * @param hardId 请求头中的当前设备主键 + * @param dto 订单与证据元数据 + * @param file JPEG 或 PNG 图片 + * @return 幂等上传结果 + */ + HardOrderEvidenceUploadResultVo upload(Long hardId, HardOrderEvidenceUploadDto dto, MultipartFile file); + + /** + * 为已成功创建的订单登记证据占位;该操作失败不得回滚订单事务。 + * + * @param order 已创建订单快照 + * @param metadataList 终端随订单提交的可选证据元数据 + * @param appVersion 终端应用版本 + */ + void registerOrderEvidence(HardOrderEntity order, List metadataList, String appVersion); + + /** + * 查询订单详情所需的证据列表并补齐中文展示名称。 + * + * @param orderId 服务端订单主键 + * @return 排序后的证据列表 + */ + List listByOrderId(Long orderId); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java index 473680f2..d5d02cba 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java @@ -52,6 +52,14 @@ public interface HardOrderService extends IService { */ Page appMchPageJoin(Page page, HardOrderDto dto, PjcloudUser user); + /** + * 按筛选条件和数据范围分页查询订单,并装配列表展示所需的关联信息。 + * + * @param page 订单分页参数 + * @param dto 订单筛选与关联信息加载选项 + * @param dataScope 后台数据范围;App 查询传 {@code null} + * @return 订单分页结果 + */ Page scopePageList(Page page, HardOrderDto dto, DataScope dataScope); /** @@ -68,6 +76,14 @@ public interface HardOrderService extends IService { HardOrderVo join(Long id); + /** + * 查询后台管理端订单详情并填充长期识别证据;调用入口负责沿用订单查看权限。 + * + * @param id 订单主键 + * @return 订单详情及识别证据;订单不存在时返回 {@code null} + */ + HardOrderVo joinWithEvidence(Long id); + /** * 查询并校验 App 用户可查看的订单详情。 */ @@ -268,11 +284,29 @@ public interface HardOrderService extends IService { void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce); + /** + * 按售后单隔离库存幂等业务号执行扣减或恢复;每日菜谱退款必须传售后单 ID,避免多次部分退款互相覆盖。 + * + * @param order 原订单 + * @param refundCarList 本次退款商品;为空表示整单商品 + * @param isReduce {@link YesNoEnum#YES} 扣库存,{@link YesNoEnum#NO} 恢复库存 + * @param afterSaleId 本次售后单 ID,非退款调用可为空 + */ + void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce, Long afterSaleId); + /** * 核销硬件二维码 */ Boolean handCancelCode(HandCancelCodeDto dto); + /** + * 将误核销的线上预订订单恢复到待核销状态,并同步恢复对应预约二维码。 + * + * @param orderId 已核销的线上预订订单主键 + * @return 订单及二维码均恢复成功时返回 {@code true} + */ + Boolean rollbackReserveVerification(Long orderId); + /** * 核销二维码 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java index 3c1e3be6..43a8c4ba 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java @@ -357,7 +357,8 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic if (!availability.available()) { throw new RuntimeException(availability.unavailableReason()); } - return new ReservationValidation(appt, appt.getHourType(), targetDate, null, availability.endDateTime()); + return new ReservationValidation(appt, appt.getHourType(), targetDate, null, availability.endDateTime(), + availability.endDateTime()); } private ReservationValidation validateCycleReservation(Long mchId, Long apptRuleId, String hourType, @@ -377,7 +378,34 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic if (!match.availability().available()) { throw new RuntimeException(match.availability().unavailableReason()); } - return new ReservationValidation(null, hourType, apptDate, selected.getId(), match.availability().endDateTime()); + return new ReservationValidation(null, hourType, apptDate, selected.getId(), match.availability().endDateTime(), + resolveCycleUsageEndDateTime(mchId, hourType, apptDate)); + } + + /** + * 周期预约的开放窗口结束时间不是餐段结束时间;这里按已启用的实际 hard_appt 餐段解析, + * 无法唯一解析时返回空值,调用方会放弃过期退款快照以避免误退。 + * + * @param mchId 订单所属商户 ID + * @param hourType 订单选中的标准餐段编码 + * @param apptDate 实际用餐日期 + * @return 匹配餐段的实际结束时间;缺失、重复或时间配置异常时返回 {@code null} + */ + private LocalDateTime resolveCycleUsageEndDateTime(Long mchId, String hourType, LocalDate apptDate) { + List candidates = hardApptService.list(new LambdaQueryWrapper() + .eq(HardApptEntity::getMchId, mchId) + .eq(HardApptEntity::getHourType, hourType) + .eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode()) + .orderByAsc(HardApptEntity::getId)); + if (candidates.size() != 1) { + return null; + } + HardApptEntity appt = candidates.get(0); + HardApptTimeTypeEnum endType = HardApptTimeTypeEnum.valueOfByType(appt.getEndTimeType()); + if (endType == null || appt.getEndTime() == null) { + return null; + } + return apptDate.plusDays(endType.getDay()).atTime(appt.getEndTime()); } private CycleMatch matchCycleRule(HardApptCycleRuleVo rule, String hourType, diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardBroadcastServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardBroadcastServiceImpl.java index f7cfbfe0..0d70dd8a 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardBroadcastServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardBroadcastServiceImpl.java @@ -214,6 +214,73 @@ public class HardBroadcastServiceImpl extends ServiceImpl() + .eq(HardBroadcastEntity::getBizId, orderdetail.getId()) + .orderByDesc(HardBroadcastEntity::getCreateTime) + .orderByDesc(HardBroadcastEntity::getId) + .last("limit 1")); + if (ObjectUtil.isEmpty(broadcastDetail)) return; + baseMapper.update(null, new LambdaUpdateWrapper() + .set(HardBroadcastEntity::getIsBroadcast, YesNoEnum.YES.getCode()) + .set(HardBroadcastEntity::getIsDone, YesNoEnum.NO.getCode()) + .set(HardBroadcastEntity::getIsDoneTime, null) + .eq(HardBroadcastEntity::getBizId, String.valueOf(orderdetail.getId()))); + + UserInfo userInfo; + try { + R userResult = remoteUserService.getInnerByUserId(orderdetail.getUserId()); + userInfo = userResult == null ? null : userResult.getData(); + } catch (Exception exception) { + log.warn("|{}|恢复预约广播时查询用户失败,orderId:|{}|,errorType:|{}|", TAG, + orderdetail.getId(), exception.getClass().getSimpleName()); + return; + } + if (userInfo == null || userInfo.getSysUser() == null) return; + List hardList = hardManagerMapper.selectList(new LambdaQueryWrapper() + .eq(HardManagerEntity::getMerchantId, orderdetail.getMerchantId()) + .eq(HardManagerEntity::getIsTakeFood, YesNoEnum.YES.getCode())); + if (ObjectUtil.isEmpty(hardList)) return; + for (HardManagerEntity hard : hardList) { + try { + String topic = hard.getMainTopic().replaceAll("/#", ""); + HardTakeFoodBroadcastMsg msg = new HardTakeFoodBroadcastMsg(); + msg.setTopic(topic); + msg.setMchDeptId(hard.getDeptId()); + msg.setHardwareId(hard.getId()); + msg.setAction(MqttActionEnum.TAKE_FOOD_BROADCAST); + msg.setUserId(String.valueOf(userInfo.getSysUser().getUserId())); + msg.setUsername(userInfo.getSysUser().getName()); + msg.setUserMobile(userInfo.getSysUser().getPhone()); + msg.setId(String.valueOf(broadcastDetail.getId())); + msg.setOrderId(String.valueOf(orderdetail.getId())); + msg.setTitle(broadcastDetail.getTitle()); + msg.setContent(broadcastDetail.getContent()); + msg.setBroadcastMaxCount(broadcastDetail.getBroadcastMaxCount()); + msg.setProductDesc(orderdetail.getProductDesc()); + msg.setPayPrice(orderdetail.getPayPrice()); + msg.setHourType(orderdetail.getHourType()); + msg.setIsDone(YesNoEnum.NO.getCode()); + remoteMqttEventService.sendTakeFoodBroadcast(msg); + } catch (Exception exception) { + log.warn("|{}|恢复预约广播实时通知失败,orderId:|{}|,hardId:|{}|,errorType:|{}|", TAG, + orderdetail.getId(), hard.getId(), exception.getClass().getSimpleName()); + } + } + } + /** * android完成一轮广播 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/VerificationRecordServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/VerificationRecordServiceImpl.java index c4a17493..b9364e4d 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/VerificationRecordServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/VerificationRecordServiceImpl.java @@ -22,15 +22,28 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Objects; +/** 终端预约核销记录查询服务,统一使用订单独立核销时间完成筛选和展示。 */ @Service @RequiredArgsConstructor public class VerificationRecordServiceImpl implements VerificationRecordService { + /** 当前接口支持的预约核销类型协议值。 */ private static final String TYPE_RESERVATION = "RESERVATION"; + /** 设备服务,用于校验请求设备存在且与查询设备一致。 */ private final HardManagerService hardManagerService; + + /** 订单服务,用于按设备、订单类型、状态和核销时间分页查询。 */ private final HardOrderService hardOrderService; + /** + * 分页查询指定设备在日期范围内完成的预约核销记录。 + * + * @param page 分页参数,页码最小为 1、每页最大为 50 + * @param hardId 当前请求头中的设备主键 + * @param dto 核销类型、设备和日期范围查询条件 + * @return 已按核销时间倒序排列的终端核销记录分页 + */ @Override public Page page(Page page, Long hardId, VerificationRecordQueryDto dto) { if (!Objects.equals(hardId, dto.getHardId())) { @@ -57,7 +70,7 @@ public class VerificationRecordServiceImpl implements VerificationRecordService query.setOrderStatusCodes(List.of(HardOrderStatusEnum.CANCEL.getType())); // 活动核销历史上复用了预约订单类型,支付方式范围需明确排除 ACT_CANCEL。 query.setExcludeOrderPayTypeCodes(List.of(HardPayTypeEnum.ACT_CANCEL.getType())); - query.setUpdateTimeList(new LocalDateTime[]{startDate.atStartOfDay(), endDate.plusDays(1).atStartOfDay()}); + query.setVerificationTimeList(new LocalDateTime[]{startDate.atStartOfDay(), endDate.atTime(23, 59, 59)}); long current = Math.max(1, page.getCurrent()); long size = Math.min(50, Math.max(1, page.getSize())); @@ -71,6 +84,12 @@ public class VerificationRecordServiceImpl implements VerificationRecordService return result; } + /** + * 将订单查询结果转换为终端核销记录展示对象。 + * + * @param order 已完成核销的预约订单 + * @return 终端核销记录 + */ private static VerificationRecordVo convert(HardOrderVo order) { VerificationRecordVo vo = new VerificationRecordVo(); vo.setOrderId(order.getId()); @@ -87,7 +106,15 @@ public class VerificationRecordServiceImpl implements VerificationRecordService return vo; } + /** + * 解析核销记录的展示时间;新数据使用独立核销时间,旧数据按更新时间、支付时间、创建时间依次降级。 + * + * @param order 已核销订单 + * @return 可供终端展示的核销时间,历史数据全部缺失时返回 {@code null} + */ private static LocalDateTime resolveVerificationTime(HardOrderVo order) { + if (order.getVerificationTime() != null) return order.getVerificationTime(); + // 历史订单在新增独立字段前没有核销时间,兼容返回最近更新时间,避免旧记录在终端列表中变为空。 if (order.getUpdateTime() != null) return order.getUpdateTime(); if (order.getPayTime() != null) return order.getPayTime(); return order.getCreateTime(); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/hard/HardThirdSelfZyServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/hard/HardThirdSelfZyServiceImpl.java index 1b9958b1..ed069be6 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/hard/HardThirdSelfZyServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/hard/HardThirdSelfZyServiceImpl.java @@ -164,9 +164,8 @@ public class HardThirdSelfZyServiceImpl implements HardThirdSelfZyService { .content(HardSaleStatusEnum.COMPLETE.getContent()) .build(); hardAfterSaleLogMapper.insert(saveLogObj); - // 执行退款 + // 正元订单不属于每日菜谱,普通库存接口没有跨服务补偿能力,保持先退款后恢复库存。 hardAfterSaleService.refund(saveObj.getId(), "zy"); - // 退库存 hardOrderService.fastenerInventory(order, YesNoEnum.NO); HardCallbackRefundVo result = new HardCallbackRefundVo(); result.setCode(HardCallbackRefundStatus.SUCCESS); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java index bf69eb2a..98ca45dd 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java @@ -23,6 +23,7 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum; +import com.bsd.pjcloud.hard.api.constant.HardProductSourceEnum; import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; @@ -31,6 +32,7 @@ import com.bsd.pjcloud.hard.api.constant.refund.HardAfterSaleOperateTypeEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardSaleStatusEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardSaleTypeEnum; +import com.bsd.pjcloud.hard.api.constant.refund.HardAfterSaleWayEnum; import com.bsd.pjcloud.hard.api.dto.order.*; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundApplyDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundItemDto; @@ -142,7 +144,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl orderList = hardOrderMapper.list(hardOrderDto); HardUtil.convertOrderList(list.getRecords(), CollectionUtils.convertMap(orderList, HardOrderEntity::getId)); } - fillAutoRefundTaskStatus(list.getRecords()); + fillAutoRefundTaskInfo(list.getRecords()); } return list; } @@ -167,7 +169,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl walletRefundResult = walletRechargeService.refund(hardRefundDto); if (walletRefundResult == null || !walletRefundResult.isOk() || ObjectUtil.isEmpty(walletRefundResult.getData())) { throw new RuntimeException("钱包退款错误"); @@ -639,7 +657,15 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId); - if (ObjectUtil.isEmpty(refundCars)) { - hardOrderService.fastenerInventory(order, YesNoEnum.NO); - } else { - hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO); - } recomputeOrderRefundState(order.getId()); } + /** + * 过期预约任务的系统整单退款。订单和任务已由任务状态机串行保护,这里仍锁订单行并重新核对可退条件, + * 防止核销、人工退款或重复调度在任务到期前改变订单状态。退库存只读取任务快照,不读取当前商户配置。 + * + * @param task 过期预约任务,首次执行时售后单 ID 为空 + * @return 创建或复用的系统售后单 ID + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Long completeExpiredReserveRefund(HardAutoRefundTaskEntity task) { + return completeExpiredReserveRefund(task, true); + } + + /** + * 过期预约任务的系统整单退款实现。自动调度需要校验当前开关,管理端逐单业务退款不受商户关闭开关影响。 + * 订单、售后和库存仍统一由订单锁与本地事务保护,避免人工操作绕过支付、核销和重复退款校验。 + * + * @param task 过期预约任务,首次执行时售后单 ID 为空 + * @param enforceAutoSwitch 自动调度/重试为 {@code true},管理端逐单退款为 {@code false} + * @return 创建或复用的系统售后单 ID + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Long completeExpiredReserveRefund(HardAutoRefundTaskEntity task, boolean enforceAutoSwitch) { + if (task == null || task.getOrderId() == null) { + throw new RuntimeException("过期预约任务数据异常"); + } + if (task.getTriggerTime() == null || task.getTriggerTime().isAfter(LocalDateTime.now())) { + throw new RuntimeException("过期预约退款尚未到达触发时间"); + } + HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(task.getOrderId()); + if (order == null) { + throw new RuntimeException("订单不存在"); + } + if (!isDailyMenuOrder(order) || !Objects.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType()) + || !Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType()) + || !Objects.equals(order.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType())) { + throw new RuntimeException("订单不满足过期预约退款条件"); + } + List afterSales = listOrderAfterSales(order.getId()); + if (afterSales.stream().anyMatch(item -> !Objects.equals(item.getId(), task.getAfterSaleId()) + && Objects.equals(item.getStatus(), HardSaleStatusEnum.APPLY.getStatus()))) { + throw new RuntimeException("订单已有处理中售后"); + } + int completedRefundPrice = sumRefundPrice(afterSales, HardSaleStatusEnum.COMPLETE.getStatus()); + if (completedRefundPrice > 0 || safePrice(order.getPayPrice()) <= 0) { + throw new RuntimeException("订单已退款或金额异常"); + } + if (task.getAfterSaleId() == null) { + throw new RuntimeException("过期预约退款售后单尚未准备完成"); + } + HardAfterSaleEntity afterSale = baseMapper.selectById(task.getAfterSaleId()); + if (afterSale == null || !Objects.equals(afterSale.getStatus(), HardSaleStatusEnum.APPLY.getStatus())) { + throw new RuntimeException("过期预约退款售后单状态异常"); + } + if (Objects.equals(task.getRestoreInventory(), YesNoEnum.YES.getCode())) { + restoreRefundInventory(order, afterSale.getId()); + } + refundInCurrentTransaction(afterSale.getId(), "hard_expired", + enforceAutoSwitch ? task.getConfigId() : null, + enforceAutoSwitch ? WalletRechargeRefundDto.AUTO_REFUND_TASK_TYPE_EXPIRED_RESERVE : null); + HardAfterSaleEntity completed = HardAfterSaleEntity.builder().id(afterSale.getId()) + .status(HardSaleStatusEnum.COMPLETE.getStatus()).auditTime(LocalDateTime.now()).refundTime(LocalDateTime.now()).build(); + if (baseMapper.updateById(completed) != 1) { + throw new RuntimeException("更新过期退款售后状态失败"); + } + insertAfterSaleLog(afterSale.getId(), afterSale.getUserId(), HardAfterSaleOperateTypeEnum.SYSTEM_EXPIRED_RESERVE_REFUND.getType(), + HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus(), + HardAfterSaleOperateTypeEnum.SYSTEM_EXPIRED_RESERVE_REFUND.getContent()); + recomputeOrderRefundState(order.getId()); + return afterSale.getId(); + } + + /** + * 在独立事务中创建过期预约系统售后单和商品快照;不调用钱包,提交后由任务状态机执行资金退款。 + * + * @param task 过期预约任务,订单已由任务状态机按订单锁串行 + * @return 新建系统售后单 ID + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Long prepareExpiredReserveRefund(HardAutoRefundTaskEntity task) { + if (task == null || task.getOrderId() == null || task.getAfterSaleId() != null) { + throw new RuntimeException("过期预约退款任务数据异常"); + } + if (task.getTriggerTime() == null || task.getTriggerTime().isAfter(LocalDateTime.now())) { + throw new RuntimeException("过期预约退款尚未到达触发时间"); + } + HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(task.getOrderId()); + if (order == null) { + throw new RuntimeException("订单不存在"); + } + if (!isDailyMenuOrder(order) || !Objects.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType()) + || !Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType()) + || !Objects.equals(order.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType())) { + throw new RuntimeException("订单不满足过期预约退款条件"); + } + List afterSales = listOrderAfterSales(order.getId()); + if (afterSales.stream().anyMatch(item -> Objects.equals(item.getStatus(), HardSaleStatusEnum.APPLY.getStatus()))) { + throw new RuntimeException("订单已有处理中售后"); + } + if (sumRefundPrice(afterSales, HardSaleStatusEnum.COMPLETE.getStatus()) > 0 || safePrice(order.getPayPrice()) <= 0) { + throw new RuntimeException("订单已退款或金额异常"); + } + HardAfterSaleEntity afterSale = HardAfterSaleEntity.builder() + .status(HardSaleStatusEnum.APPLY.getStatus()) + .no("hard_expired_" + order.getId()) + .orderId(order.getId()) + .orderNo(String.valueOf(order.getId())) + .userId(order.getUserId()) + .way(HardAfterSaleWayEnum.REFUND.getType()) + .type(HardSaleTypeEnum.IN_SALE.getType()) + .refundPrice(safePrice(order.getPayPrice())) + .applyReason("预约订单过期自动退款") + .applyDescription("系统按预约餐段结束时间自动退款") + .build(); + if (baseMapper.insert(afterSale) != 1) { + throw new RuntimeException("创建过期预约退款售后单失败"); + } + hardAfterSaleCarService.create(buildRemainingRefundCars(order, getOriginalCars(order)), order, afterSale); + insertAfterSaleLog(afterSale.getId(), order.getUserId(), HardAfterSaleOperateTypeEnum.SYSTEM_EXPIRED_RESERVE_REFUND.getType(), + order.getOrderStatus(), HardSaleStatusEnum.APPLY.getStatus(), HardAfterSaleOperateTypeEnum.SYSTEM_EXPIRED_RESERVE_REFUND.getContent()); + return afterSale.getId(); + } + @Override public Boolean agreeAfterSale(Long userId, Long id) { HardAfterSaleEntity current = baseMapper.selectById(id); @@ -693,13 +838,14 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCars = hardAfterSaleCarService.getByRefundId(id); - if (ObjectUtil.isEmpty(refundCars)) { - hardOrderService.fastenerInventory(order, YesNoEnum.NO); - } else { - hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO); + if (!canteenSpuInventoryOrder) { + restoreOrderInventory(order, id); } recomputeOrderRefundState(order.getId()); return true; @@ -779,33 +925,90 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundSuccessCarList = hardAfterSaleCarService.getOccupiedByCarParentId(order.getParentCarId()); + Map requestedCountByProductId = new LinkedHashMap<>(); for (HardAfterSaleCarEntity refundCar : refundCarList) { - HardCarEntity orderCar = orderCarList.stream() - .filter(v -> ObjectUtil.equal(v.getProductId(), refundCar.getProductId()) - || ObjectUtil.equal(v.getId(), refundCar.getId())) - .findFirst().orElse(null); + if (refundCar == null || (refundCar.getProductId() == null && refundCar.getId() == null)) { + throw new RuntimeException("退款商品不能为空"); + } + HardCarEntity orderCar = resolveRefundOrderCar(orderCarList, refundCar); if (orderCar == null) throw new RuntimeException("退款商品<" + refundCar.getProductName() + ">不存在"); - int refundCount = 0; - if (ObjectUtil.isNotEmpty(refundCar.getProductCount())) { - refundCount = refundCar.getProductCount(); + // 后续金额、售后快照和库存恢复都以服务端商品 ID 为准,兼容后台历史提交的购物车行 ID。 + refundCar.setProductId(orderCar.getProductId()); + // 按数量商品必须提交有效份数;称重商品历史请求可能只提交重量,数量按零参与数量校验。 + if (HardProductNatureEnum.DEFAULT.getType().equals(orderCar.getProductNature()) + && refundCar.getProductCount() == null) { + throw new RuntimeException("退款商品<" + orderCar.getProductName() + ">数量不能为空"); } - // 已经退货成功的商品列表 - if (ObjectUtil.isNotEmpty(refundSuccessCarList)) { - // 售后明细 ID 是退款记录自身 ID,不能与原购物车 ID 比较;使用服务端订单商品 ID 防止重复超额退款。 - HardAfterSaleCarEntity refundSuccessCar = refundSuccessCarList.stream() - .filter(v -> ObjectUtil.equal(v.getProductId(), orderCar.getProductId())) - .findFirst() - .orElse(null); - if (ObjectUtil.isNotEmpty(refundSuccessCar)) { - refundCount += refundSuccessCar.getProductCount(); - } + if (refundCar.getProductCount() != null && refundCar.getProductCount() < 0) { + throw new RuntimeException("退款商品<" + orderCar.getProductName() + ">数量不能为负数"); } - int originalCount = orderCarList.stream().filter(item -> ObjectUtil.equal(item.getProductId(), orderCar.getProductId())) - .map(HardCarEntity::getProductCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); - if (originalCount < refundCount) { - throw new RuntimeException("退款商品<" + refundCar.getProductName() + ">数量超出"); + int requestCount = refundCar.getProductCount() == null ? 0 : refundCar.getProductCount(); + try { + requestedCountByProductId.merge(orderCar.getProductId(), requestCount, Math::addExact); + } catch (ArithmeticException exception) { + throw new RuntimeException("退款商品<" + orderCar.getProductName() + ">数量异常"); } } + Map occupiedCountByProductId = refundSuccessCarList.stream() + .filter(item -> item != null && item.getProductId() != null) + .collect(Collectors.toMap(HardAfterSaleCarEntity::getProductId, + item -> item.getProductCount() == null ? 0 : item.getProductCount(), + (left, right) -> { + try { + return Math.addExact(left, right); + } catch (ArithmeticException exception) { + throw new RuntimeException("历史退款商品数量异常"); + } + }, LinkedHashMap::new)); + Map originalCountByProductId = orderCarList.stream() + .filter(item -> item.getProductId() != null) + .collect(Collectors.toMap(HardCarEntity::getProductId, + item -> item.getProductCount() == null ? 0 : item.getProductCount(), + (left, right) -> { + try { + return Math.addExact(left, right); + } catch (ArithmeticException exception) { + throw new RuntimeException("订单商品数量异常"); + } + }, LinkedHashMap::new)); + requestedCountByProductId.forEach((productId, requestedCount) -> { + int occupiedCount = occupiedCountByProductId.getOrDefault(productId, 0); + int originalCount = originalCountByProductId.getOrDefault(productId, 0); + final int totalRefundCount; + try { + totalRefundCount = Math.addExact(occupiedCount, requestedCount); + } catch (ArithmeticException exception) { + throw new RuntimeException("退款商品数量异常"); + } + if (totalRefundCount > originalCount) { + HardCarEntity orderCar = orderCarList.stream() + .filter(item -> ObjectUtil.equal(item.getProductId(), productId)) + .findFirst().orElse(null); + throw new RuntimeException("退款商品<" + (orderCar == null ? productId : orderCar.getProductName()) + ">数量超出"); + } + }); + } + + /** + * 将退款明细解析到原订单购物车行,优先使用商品 ID,兼容后台历史提交的购物车行 ID。 + * + * @param orderCarList 原订单购物车快照 + * @param refundCar 客户端提交或服务端组装的退款明细 + * @return 匹配的原订单购物车行;无法匹配时返回 {@code null} + */ + private HardCarEntity resolveRefundOrderCar(List orderCarList, HardAfterSaleCarEntity refundCar) { + if (refundCar == null) { + return null; + } + HardCarEntity byProductId = orderCarList.stream() + .filter(item -> ObjectUtil.equal(item.getProductId(), refundCar.getProductId())) + .findFirst().orElse(null); + if (byProductId != null) { + return byProductId; + } + return orderCarList.stream() + .filter(item -> ObjectUtil.equal(item.getId(), refundCar.getId())) + .findFirst().orElse(null); } /** @@ -890,21 +1093,29 @@ public class HardAfterSaleServiceImpl extends ServiceImpl records) { + private void fillAutoRefundTaskInfo(List records) { List afterSaleIds = records.stream().map(HardAfterSaleVo::getId).filter(Objects::nonNull).distinct().toList(); if (afterSaleIds.isEmpty()) { return; } - Map taskStatusMap = hardAutoRefundTaskService.list( + Map taskMap = hardAutoRefundTaskService.list( new LambdaQueryWrapper() .in(HardAutoRefundTaskEntity::getAfterSaleId, afterSaleIds)) .stream().collect(Collectors.toMap(HardAutoRefundTaskEntity::getAfterSaleId, - HardAutoRefundTaskEntity::getStatus, (first, second) -> first)); - records.forEach(item -> item.setAutoRefundTaskStatus(taskStatusMap.get(item.getId()))); + item -> item, (first, second) -> first)); + records.forEach(item -> { + HardAutoRefundTaskEntity task = taskMap.get(item.getId()); + if (task != null) { + item.setAutoRefundTaskStatus(task.getStatus()); + item.setAutoRefundTaskType(task.getTaskType()); + item.setAutoRefundRuleType(task.getRuleType()); + item.setAutoRefundEligibilityDeadline(task.getEligibilityDeadline()); + } + }); } /** @@ -1445,13 +1656,22 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCars = isDailyMenuOrder(order) + ? buildDailyMenuAdminRefundCars(order, dto.getCarList()) : dto.getCarList(); + hardAfterSaleCarService.createAdmin(refundCars, order, saveObj); } // 添加售后进度 HardAfterSaleLogEntity saveLogObj = HardAfterSaleLogEntity.builder() @@ -1551,8 +1773,13 @@ public class HardAfterSaleServiceImpl extends ServiceImpl carList = dto.getCarList().stream().map((item) -> HardCarEntity.builder() .productId(item.getProductId()) @@ -1561,10 +1788,12 @@ public class HardAfterSaleServiceImpl extends ServiceImpl售后商品快照是服务端核定的可信数量:存在快照时仅恢复本次部分退款商品, + * 快照为空时兼容整单退款并恢复父购物车全部商品。库存服务使用售后单 ID 做幂等隔离, + * 使重复回调不会重复增加库存。

+ * + * @param order 原每日菜谱预约订单或机器收银订单 + * @param afterSaleId 本次售后单 ID,不能为空 + */ + private void restoreRefundInventory(HardOrderEntity order, Long afterSaleId) { + if (afterSaleId == null) { + throw new RuntimeException("食堂菜品退款库存缺少售后单 ID"); + } + List refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId); + if (ObjectUtil.isEmpty(refundCars)) { + hardOrderService.fastenerInventory(order, null, YesNoEnum.NO, afterSaleId); + return; + } + hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO, afterSaleId); + } + + /** + * 恢复普通商品库存,保持旧库存服务的调用协议和退款后执行顺序。 + * + * @param order 原订单 + * @param afterSaleId 售后单 ID,仅用于读取部分退款商品快照 + */ + private void restoreOrderInventory(HardOrderEntity order, Long afterSaleId) { + List refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId); + if (ObjectUtil.isEmpty(refundCars)) { + hardOrderService.fastenerInventory(order, YesNoEnum.NO); + } else { + hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO); + } + } + /** * 使用下单时保存的购物车成交价快照计算每日菜谱退款金额,退款时不再依赖可能已下架或改价的商品主数据。 * 请求仅提供退款数量,商品和价格均以服务端订单购物车为准。 @@ -1599,26 +1885,116 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCountByProductId = new LinkedHashMap<>(); try { for (HardAfterSaleCarEntity refundCar : refundCarList) { + HardCarEntity orderCar = resolveRefundOrderCar(orderCarList, refundCar); + if (orderCar == null || orderCar.getProductId() == null) { + throw new RuntimeException("退款商品不存在"); + } int refundCount = refundCar.getProductCount() == null ? 0 : refundCar.getProductCount(); - if (refundCount <= 0) { + if (refundCount < 0) { + throw new RuntimeException("退款商品数量不能为负数"); + } + if (refundCount == 0) { continue; } - HardCarEntity orderCar = orderCarList.stream() - .filter(item -> ObjectUtil.equal(item.getId(), refundCar.getId())) - .findFirst() - .orElseThrow(() -> new RuntimeException("退款商品不存在")); - if (orderCar.getProductPrice() == null || orderCar.getProductPrice() < 0) { - throw new RuntimeException("退款商品价格异常"); - } - refundPrice = Math.addExact(refundPrice, Math.multiplyExact(orderCar.getProductPrice(), refundCount)); + refundCountByProductId.merge(orderCar.getProductId(), refundCount, Math::addExact); } + + int refundPrice = 0; + for (Map.Entry entry : refundCountByProductId.entrySet()) { + int remainingCount = entry.getValue(); + List productCars = orderCarList.stream() + .filter(item -> ObjectUtil.equal(item.getProductId(), entry.getKey())) + .toList(); + for (HardCarEntity orderCar : productCars) { + if (remainingCount <= 0) { + break; + } + int originalCount = orderCar.getProductCount() == null ? 0 : orderCar.getProductCount(); + int currentCount = Math.min(remainingCount, Math.max(originalCount, 0)); + if (currentCount <= 0) { + continue; + } + if (orderCar.getProductPrice() == null || orderCar.getProductPrice() < 0) { + throw new RuntimeException("退款商品价格异常"); + } + refundPrice = Math.addExact(refundPrice, + Math.multiplyExact(orderCar.getProductPrice(), currentCount)); + remainingCount -= currentCount; + } + if (remainingCount > 0) { + throw new RuntimeException("退款商品数量超出订单数量"); + } + } + return refundPrice; } catch (ArithmeticException exception) { throw new RuntimeException("退款金额超出计算范围", exception); } - return refundPrice; + } + + /** + * 按原订单购物车生成后台每日菜谱退款快照,丢弃客户端携带的名称、价格和总价,避免后续整单退款读取不可信金额。 + * 同一商品多行时先聚合数量,库存恢复和历史退款占用保持同一商品维度。 + * + * @param order 每日菜谱预约订单 + * @param refundCarList 后台提交的退款商品及数量 + * @return 使用原订单商品资料和成交价计算的可信售后明细 + */ + private List buildDailyMenuAdminRefundCars(HardOrderEntity order, + List refundCarList) { + List orderCarList = hardCarService.getByParentCarId(order.getParentCarId()); + if (ObjectUtil.isEmpty(orderCarList)) { + throw new RuntimeException("订单购物车不存在"); + } + Map orderCarMap = orderCarList.stream() + .filter(item -> item.getProductId() != null) + .collect(Collectors.toMap(HardCarEntity::getProductId, item -> item, (left, right) -> left, + LinkedHashMap::new)); + Map refundCountByProductId = new LinkedHashMap<>(); + for (HardAfterSaleCarEntity refundCar : refundCarList) { + HardCarEntity orderCar = resolveRefundOrderCar(orderCarList, refundCar); + int refundCount = refundCar == null || refundCar.getProductCount() == null ? 0 : refundCar.getProductCount(); + if (orderCar == null || orderCar.getProductId() == null) { + throw new RuntimeException("退款商品不存在"); + } + if (refundCount < 0) { + throw new RuntimeException("退款商品数量不能为负数"); + } + if (refundCount == 0) { + continue; + } + try { + refundCountByProductId.merge(orderCar.getProductId(), refundCount, Math::addExact); + } catch (ArithmeticException exception) { + throw new RuntimeException("退款商品数量异常", exception); + } + } + List result = new ArrayList<>(refundCountByProductId.size()); + for (Map.Entry entry : refundCountByProductId.entrySet()) { + HardCarEntity orderCar = orderCarMap.get(entry.getKey()); + if (orderCar == null || orderCar.getProductPrice() == null || orderCar.getProductPrice() < 0) { + throw new RuntimeException("退款商品价格异常"); + } + long totalPrice; + try { + totalPrice = Math.multiplyExact((long) orderCar.getProductPrice(), entry.getValue()); + } catch (ArithmeticException exception) { + throw new RuntimeException("退款金额超出计算范围", exception); + } + HardAfterSaleCarEntity trustedCar = new HardAfterSaleCarEntity(); + trustedCar.setProductId(orderCar.getProductId()); + trustedCar.setProductName(orderCar.getProductName()); + trustedCar.setProductPic(orderCar.getProductPic()); + trustedCar.setProductPrice(orderCar.getProductPrice().longValue()); + trustedCar.setProductNature(orderCar.getProductNature()); + trustedCar.setProductCount(entry.getValue()); + trustedCar.setProductWeight(orderCar.getProductWeight()); + trustedCar.setTotalPrice(totalPrice); + result.add(trustedCar); + } + return result; } /** @@ -1719,10 +2095,16 @@ public class HardAfterSaleServiceImpl extends ServiceImpl() + .eq(HardAutoRefundTaskEntity::getOrderId, order.getId()) + .eq(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())); + if (existing != null) { + return existing; + } + HardAutoRefundTaskEntity task = HardAutoRefundTaskEntity.builder() + .taskType(HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) + .orderId(order.getId()).mchId(order.getMerchantId()) + .configId(order.getExpiredRefundConfigId()) + .triggerTime(order.getExpiredRefundDeadline()) + .restoreInventory(order.getExpiredRefundRestoreInventory()) + .status(HardAutoRefundTaskStatusEnum.PENDING.getStatus()) + .autoAttemptCount(0).totalAttemptCount(0) + .nextRetryTime(order.getExpiredRefundDeadline()) + .build(); + if (baseMapper.insert(task) != 1) { + throw new RuntimeException("创建过期预约退款任务失败"); + } + return task; + } + /** * 事务提交成功后在当前请求线程同步执行首次退款,执行失败只更新任务,不改变创建接口返回值。 * @@ -240,6 +284,17 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl= MAX_AUTO_ATTEMPTS) { + if (List.of(HardAutoRefundTriggerTypeEnum.MANUAL, HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL).contains(triggerType) + || autoAttempts >= MAX_AUTO_ATTEMPTS) { task.setStatus(HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus()); task.setNextRetryTime(null); } else { @@ -595,12 +729,22 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl pageExpiredCandidates(Page page, + HardAutoRefundTaskPageDto dto) { + LocalDateTime now = LocalDateTime.now(); + Page entityPage = baseMapper.pageExpiredCandidates(page, dto, now); + List records = BeanUtils.toBean(entityPage.getRecords(), HardAutoRefundTaskVo.class); + fillAfterSaleData(records); + fillOrderData(records); + fillUserData(records); + fillMerchantData(records); + Page result = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); + result.setRecords(records); + return result; + } + + /** 批量补充过期候选订单日期和餐段,避免把配置变更后的规则重新解析为退款时间。 */ + private void fillOrderData(List records) { + List orderIds = records.stream().map(HardAutoRefundTaskVo::getOrderId).filter(Objects::nonNull).distinct().toList(); + if (orderIds.isEmpty()) return; + Map orders = hardOrderMapper.selectBatchIds(orderIds).stream() + .collect(Collectors.toMap(HardOrderEntity::getId, Function.identity(), (first, second) -> first)); + records.forEach(item -> { + HardOrderEntity order = orders.get(item.getOrderId()); + if (order != null) { + item.setUserId(order.getUserId()); + item.setRefundPrice(order.getPayPrice()); + item.setApptUseDate(order.getApptUseDate()); + item.setHourType(order.getHourType()); + } + }); + } + /** * 按售后唯一键查询任务。 * @@ -870,9 +1081,19 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl() .and(wrapper -> wrapper.eq(HardAutoRefundTaskEntity::getStatus, HardAutoRefundTaskStatusEnum.PENDING.getStatus()) + .and(nested -> nested.ne(HardAutoRefundTaskEntity::getTaskType, + HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) + .or(expired -> expired.eq(HardAutoRefundTaskEntity::getTaskType, + HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) + .le(HardAutoRefundTaskEntity::getTriggerTime, now))) .or(nested -> nested.eq(HardAutoRefundTaskEntity::getStatus, HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus()) - .le(HardAutoRefundTaskEntity::getNextRetryTime, now))) + .le(HardAutoRefundTaskEntity::getNextRetryTime, now) + .and(expired -> expired.ne(HardAutoRefundTaskEntity::getTaskType, + HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) + .or(expiredType -> expiredType.eq(HardAutoRefundTaskEntity::getTaskType, + HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) + .le(HardAutoRefundTaskEntity::getTriggerTime, now))))) .orderByAsc(HardAutoRefundTaskEntity::getNextRetryTime) .orderByAsc(HardAutoRefundTaskEntity::getId) .last("limit " + limit))); @@ -899,6 +1120,9 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl菜品导出以预约使用日期为批次边界,每完成一个日期就推进一次 Redis 进度;Excel 数据始终通过 + * Easy Excel 追加写入,避免把整个日期范围的商品明细一次性保留在内存中。

+ */ +@Slf4j +@Validated +@Service +@RequiredArgsConstructor +public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTotalExportService { + + /** 用户预约导出任务的 Redis Key 命名空间。 */ + private static final String USER_RESERVATION_EXPORT_REDIS_KEY = "hard:canteen-user-appt:export"; + + /** 用户预约导出任务名称。 */ + private static final String USER_RESERVATION_EXPORT_TASK_NAME = "用户预约查询导出"; + + /** 用户预约导出创建时的占位总数。 */ + private static final int USER_RESERVATION_EXPORT_INITIAL_TOTAL = 1; + + /** 用户预约导出每批读取的权限用户数。 */ + private static final int USER_RESERVATION_EXPORT_BATCH_SIZE = 200; + + /** 文件上传和任务收尾占用的额外进度步数。 */ + private static final int USER_RESERVATION_EXPORT_EXTRA_PROGRESS_STEPS = 2; + + /** 用户预约导出表头及合计行占用的固定行数。 */ + private static final int USER_RESERVATION_EXPORT_HEADER_ROW_COUNT = 2; + private static final int USER_RESERVATION_EXPORT_SUMMARY_ROW_COUNT = 1; + + /** 用户预约导出工作表允许写入的最大明细行数。 */ + private static final int MAX_USER_RESERVATION_EXPORT_ROWS = + BatchExcelExportTemplate.calculateMaxDetailRowCount(USER_RESERVATION_EXPORT_HEADER_ROW_COUNT, + USER_RESERVATION_EXPORT_SUMMARY_ROW_COUNT); + + /** 用户预约导出工作表名称。 */ + private static final String USER_RESERVATION_EXPORT_SHEET_NAME = "用户预约查询"; + + /** 用户预约导出文件目录。 */ + private static final String USER_RESERVATION_EXPORT_FILE_DIR = "hard-export"; + + /** 用户预约导出文件类型编码。 */ + private static final String USER_RESERVATION_EXPORT_FILE_TYPE = "HARD_CANTEEN_USER_APPT_EXPORT"; + + /** 用户预约导出文件 MIME 类型。 */ + private static final String USER_EXPORT_EXCEL_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + + /** 用户预约导出文件名时间格式。 */ + private static final DateTimeFormatter USER_EXPORT_FILE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + /** 用户预约明细中核销时间的 Excel 展示格式。 */ + 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; + + /** 页面协议中的已订餐状态。 */ + private static final String RESERVATION_STATUS_RESERVED = "RESERVED"; + + /** 页面协议中的未订餐状态。 */ + private static final String RESERVATION_STATUS_NOT_RESERVED = "NOT_RESERVED"; + + /** 页面协议中的无需核销状态。 */ + private static final String VERIFICATION_STATUS_NONE = "NONE"; + + /** 页面协议中的全部待核销状态。 */ + private static final String VERIFICATION_STATUS_UNVERIFIED = "UNVERIFIED"; + + /** 页面协议中的部分预约已核销状态。 */ + private static final String VERIFICATION_STATUS_PARTIAL = "PARTIAL"; + + /** 页面协议中的全部已核销状态。 */ + private static final String VERIFICATION_STATUS_VERIFIED = "VERIFIED"; + + /** 允许的订餐状态筛选值。 */ + private static final Set RESERVATION_STATUS_VALUES = Set.of( + RESERVATION_STATUS_RESERVED, RESERVATION_STATUS_NOT_RESERVED); + + /** 允许的核销状态筛选值。 */ + private static final Set VERIFICATION_STATUS_VALUES = Set.of( + VERIFICATION_STATUS_NONE, VERIFICATION_STATUS_UNVERIFIED, + VERIFICATION_STATUS_PARTIAL, VERIFICATION_STATUS_VERIFIED); + + /** 有效预约订单状态,只统计已支付和已完成核销的订单。 */ + private static final List VALID_RESERVATION_ORDER_STATUSES = HardOrderStatusEnum.getPaySuccessCodes(); + + /** 用户预约导出和订单关联的单批用户数量。 */ + private static final long USER_BATCH_SIZE = 500L; + + /** 单次订单查询允许的用户主键数量。 */ + private static final int ORDER_USER_BATCH_SIZE = 500; + + /** 用户远程服务,负责导出权限范围内的完整用户资料。 */ + private final RemoteUserService remoteUserService; + + /** 预约订单查询 Mapper,供用户预约导出直接读取预约订单。 */ + private final HardOrderMapper hardOrderMapper; + + /** 菜品预约聚合 Mapper。 */ + private final HardCarMapper hardCarMapper; + + /** 每日菜谱远程服务,用于展开套餐组成。 */ + private final RemoteDailyMenuService remoteDailyMenuService; + + /** 部门远程服务,用于补齐预约人所属部门名称。 */ + private final RemoteDeptService remoteDeptService; + + /** 餐段字典远程服务,用于输出餐段名称。 */ + private final RemoteDictService remoteDictService; + + /** 商户远程服务,用于补齐食堂名称。 */ + private final RemoteMchService remoteMchService; + + /** 文件中心远程服务。 */ + private final RemoteFileService remoteFileService; + + /** 通用异步任务执行器。 */ + private final AsyncTaskExecutorService asyncTaskExecutorService; + + /** + * 提交用户每日预约异步导出任务。 + * + *

请求线程只校验并复制筛选条件;后台任务恢复当前租户和登录用户上下文后,按权限用户分页读取并 + * 分批写入 Excel,避免长时间占用 HTTP 连接和一次性持有全部导出行。

+ * + * @param query 用户、日期范围、食堂、餐段和状态筛选条件 + * @return 已创建任务的初始进度 + */ + @Override + public AsyncTaskProgressVO submitUserReservationExport(HardCanteenUserApptQueryDto query) { + validateQuery(query); + HardCanteenUserApptQueryDto querySnapshot = copyUserReservationQuery(query); + return asyncTaskExecutorService.submitWithResult(USER_RESERVATION_EXPORT_REDIS_KEY, + USER_RESERVATION_EXPORT_TASK_NAME, USER_RESERVATION_EXPORT_INITIAL_TOTAL, + reporter -> exportUserReservations(querySnapshot, reporter)); + } + + /** + * 查询用户预约 Excel 数据。 + * + * @param query 用户、日期范围和预约状态筛选条件 + * @return 用户和日期维度的导出行,最后一行为餐段及当日金额合计 + */ + @Override + public List userReservationExport(HardCanteenUserApptQueryDto query) { + validateQuery(query); + HardCanteenUserApptQueryDto exportQuery = copyUserReservationQuery(query); + if (!narrowQueryToReservationCandidates(exportQuery)) { + return List.of(); + } + Map mealSegmentLabelMap = fetchMealSegmentLabelMap(); + List reservationRows = + listAllFilteredExportRows(exportQuery, getQueryDates(exportQuery)); + if (reservationRows.isEmpty()) { + return List.of(); + } + UserReservationExportSummary summary = + new UserReservationExportSummary(exportQuery, mealSegmentLabelMap); + summary.addRows(reservationRows); + List excelRows = new ArrayList<>(reservationRows.size() + 1); + reservationRows.stream().map(row -> toExcelRow(row, exportQuery, mealSegmentLabelMap)) + .forEach(excelRows::add); + excelRows.add(toExcelSummaryRow(summary, mealSegmentLabelMap)); + return excelRows; + } + + /** + * 在异步线程中分批查询用户预约、生成 Excel 并上传文件中心。 + * + * @param query 后台线程独占的用户预约筛选条件 + * @param reporter 当前异步任务进度报告器 + * @return 文件中心下载地址和业务文件名 + */ + private AsyncTaskResult exportUserReservations(HardCanteenUserApptQueryDto query, + AsyncTaskProgressReporter reporter) { + if (!narrowQueryToReservationCandidates(query)) { + throw new IllegalStateException("没有符合当前筛选条件的用户预约记录"); + } + Page firstUserPage = fetchExportUserPage(1L, USER_RESERVATION_EXPORT_BATCH_SIZE, query); + long userTotal = firstUserPage.getTotal(); + if (userTotal <= 0L) { + throw new IllegalStateException("没有符合当前条件的用户预约数据"); + } + validateUserReservationExportRowCapacity(userTotal, query); + int progressTotal = calculateUserReservationExportProgressTotal(userTotal); + reporter.updateTotal(progressTotal, "已确定 " + userTotal + " 位符合条件用户,将分批生成预约文件"); + + String fileName = "用户预约查询_" + LocalDateTime.now().format(USER_EXPORT_FILE_TIME_FORMATTER) + ".xlsx"; + Path outputPath = null; + try { + outputPath = Files.createTempFile("canteen-user-appt-export-", ".xlsx"); + long exportedRowTotal = writeUserReservationWorkbook(outputPath, query, firstUserPage, userTotal, + reporter); + String fileUrl = uploadUserReservationWorkbook(outputPath, fileName); + reporter.advance("用户预约导出文件已生成,共 " + exportedRowTotal + " 条"); + return AsyncTaskResult.file(fileUrl, fileName); + } catch (IllegalStateException exception) { + throw exception; + } catch (Exception exception) { + log.error("|用户预约查询导出|生成或保存文件失败,fileName:{}", fileName, exception); + throw new IllegalStateException("生成用户预约导出文件失败", exception); + } finally { + deleteTemporaryExportFile(outputPath); + } + } + + /** + * 按权限用户分页读取并把当前批次结果写入 EasyExcel 流式工作簿。 + * + *

订餐和核销状态依赖订单关联结果,因此在每批用户展开日期后执行;批次写入完成即可释放, + * 不会把整个日期范围的全部用户行同时留在 JVM 堆中。

+ * + * @param outputPath 最终上传前使用的临时 XLSX 文件 + * @param query 已冻结的筛选条件 + * @param firstUserPage 已用于取得用户总数的第一页,避免重复远程调用 + * @param userTotal 任务开始时取得的权限用户总数 + * @param reporter 当前异步任务进度报告器 + * @return 实际写入 Excel 的用户日期行数 + */ + private long writeUserReservationWorkbook(Path outputPath, HardCanteenUserApptQueryDto query, + Page firstUserPage, long userTotal, + AsyncTaskProgressReporter reporter) { + List queryDates = getQueryDates(query); + Map mealSegmentLabelMap = fetchMealSegmentLabelMap(); + long processedUserTotal = 0L; + long exportedRowTotal = 0L; + long current = 1L; + UserReservationExportSummary summary = new UserReservationExportSummary(query, mealSegmentLabelMap); + + try (ExcelWriter excelWriter = EasyExcel.write(outputPath.toFile(), HardCanteenUserApptExcelVo.class).build()) { + WriteSheet writeSheet = EasyExcel.writerSheet(USER_RESERVATION_EXPORT_SHEET_NAME).build(); + Page userPage = firstUserPage; + while (processedUserTotal < userTotal) { + List pageRecords = userPage.getRecords() == null ? List.of() : userPage.getRecords(); + if (pageRecords.isEmpty()) { + break; + } + int remainingUserCount = Math.toIntExact(Math.min(pageRecords.size(), userTotal - processedUserTotal)); + List batchUsers = pageRecords.subList(0, remainingUserCount); + List batchReservationRows = + filterRows(buildRows(batchUsers, query, queryDates), query); + long nextExportedRowTotal = exportedRowTotal + batchReservationRows.size(); + if (nextExportedRowTotal > MAX_USER_RESERVATION_EXPORT_ROWS) { + throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 " + + MAX_USER_RESERVATION_EXPORT_ROWS + " 条,请缩小查询范围后分批导出"); + } + List batchRows = batchReservationRows.stream() + .map(row -> toExcelRow(row, query, mealSegmentLabelMap)).toList(); + if (!batchRows.isEmpty()) { + excelWriter.write(batchRows, writeSheet); + summary.addRows(batchReservationRows); + } + exportedRowTotal = nextExportedRowTotal; + processedUserTotal += batchUsers.size(); + reporter.advance("已处理 " + processedUserTotal + " / " + userTotal + " 位用户,已写入 " + + exportedRowTotal + " 条预约记录"); + if (processedUserTotal >= userTotal) { + break; + } + userPage = fetchExportUserPage(++current, USER_RESERVATION_EXPORT_BATCH_SIZE, query); + } + if (processedUserTotal != userTotal) { + throw new IllegalStateException("导出用户数据在执行期间发生变化,预期 " + userTotal + + " 位,实际处理 " + processedUserTotal + " 位"); + } + if (exportedRowTotal == 0L) { + throw new IllegalStateException("没有符合当前筛选条件的用户预约记录"); + } + excelWriter.write(List.of(toExcelSummaryRow(summary, mealSegmentLabelMap)), writeSheet); + } + return exportedRowTotal; + } + + /** + * 根据权限用户总数计算“用户查询批次数 + 文件上传和任务收尾”的进度总步数。 + * + * @param userTotal 当前筛选条件下的权限用户总数 + * @return 统一进度组件使用的总步数 + */ + private int calculateUserReservationExportProgressTotal(long userTotal) { + return BatchExcelExportTemplate.calculateProgressTotal(userTotal, USER_RESERVATION_EXPORT_BATCH_SIZE, + USER_RESERVATION_EXPORT_EXTRA_PROGRESS_STEPS); + } + + /** + * 在没有订餐或核销状态筛选时,提前校验确定会生成的“用户 × 日期”明细行数。 + * + *

存在关联状态筛选时最终行数只能在分批关联订单后确定,写入过程仍会执行单工作表上限保护。

+ * + * @param userTotal 当前用户筛选和数据权限范围内的用户总数 + * @param query 已冻结的日期和预约状态筛选条件 + */ + private void validateUserReservationExportRowCapacity(long userTotal, HardCanteenUserApptQueryDto query) { + if (hasJoinedStatusFilter(query)) { + return; + } + long rowTotal; + try { + rowTotal = Math.multiplyExact(userTotal, (long) getQueryDates(query).size()); + } catch (ArithmeticException exception) { + throw new IllegalStateException("用户预约导出数据量过大,请缩小查询范围后分批导出", exception); + } + if (rowTotal > MAX_USER_RESERVATION_EXPORT_ROWS) { + throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 " + + MAX_USER_RESERVATION_EXPORT_ROWS + " 条,请缩小查询范围后分批导出"); + } + } + + /** + * 将已关闭的完整 XLSX 临时文件上传到统一文件中心。 + * + * @param outputPath 已完成写入的临时文件 + * @param fileName 用户下载时使用的业务文件名 + * @return 文件中心提供的下载地址 + * @throws Exception 临时文件读取或远程上传失败时抛出 + */ + private String uploadUserReservationWorkbook(Path outputPath, String fileName) throws Exception { + MultipartFile exportFile = new MockMultipartFile("file", encodeUploadOriginalFilename(fileName), + USER_EXPORT_EXCEL_CONTENT_TYPE, Files.readAllBytes(outputPath)); + R uploadResult = remoteFileService.innerUpload(exportFile, USER_RESERVATION_EXPORT_FILE_DIR, null, + USER_RESERVATION_EXPORT_FILE_TYPE); + if (uploadResult == null || !uploadResult.isOk() + || !(uploadResult.getData() instanceof Map resultData)) { + throw new IllegalStateException("文件中心未返回有效的用户预约导出结果"); + } + Object resultUrl = resultData.get("url"); + if (resultUrl == null || StrUtil.isBlank(resultUrl.toString())) { + throw new IllegalStateException("文件中心未返回用户预约导出下载地址"); + } + return resultUrl.toString(); + } + + /** + * 复制用户预约查询条件,避免异步任务与控制器请求对象共享可变数组或列表。 + * + * @param source 已通过基础校验的请求筛选条件 + * @return 后台线程独占的完整筛选条件副本 + */ + private HardCanteenUserApptQueryDto copyUserReservationQuery(HardCanteenUserApptQueryDto source) { + HardCanteenUserApptQueryDto target = new HardCanteenUserApptQueryDto(); + target.setApptUseDateList(source.getApptUseDateList() == null + ? null : source.getApptUseDateList().clone()); + target.setNameOrPhone(source.getNameOrPhone()); + target.setIdentityTypes(copyList(source.getIdentityTypes())); + target.setStudentInfo(copyStudentInfo(source.getStudentInfo())); + target.setTeacherInfo(copyTeacherInfo(source.getTeacherInfo())); + target.setHasFace(source.getHasFace()); + target.setDeptIds(copyList(source.getDeptIds())); + target.setIsInclude(source.getIsInclude()); + target.setMerchantId(source.getMerchantId()); + target.setHourType(source.getHourType()); + target.setReservationStatus(source.getReservationStatus()); + target.setVerificationStatus(source.getVerificationStatus()); + target.setUserIds(copyList(source.getUserIds())); + return target; + } + + /** + * 复制可能为空的筛选列表,保留空值与空集合的原始查询语义。 + * + * @param source 请求对象中的可变列表 + * @param 列表元素类型 + * @return 空值保持为空,非空列表返回独立副本 + */ + private List copyList(List source) { + return source == null ? null : new ArrayList<>(source); + } + + /** + * 复制学生身份查询条件,确保异步导出线程不共享请求对象的可变扩展资料。 + * + * @param source 原始学生身份查询条件 + * @return 独立的学生身份查询条件;源值为空时返回空值 + */ + 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 独立的老师身份查询条件;源值为空时返回空值 + */ + private SysUserTeacherDto copyTeacherInfo(SysUserTeacherDto source) { + if (source == null) return null; + SysUserTeacherDto target = new SysUserTeacherDto(); + target.setTeacherNo(source.getTeacherNo()); + return target; + } + + /** + * 按文件服务约定编码中文原始文件名,避免 ISO-8859-1 到 UTF-8 的转换产生乱码。 + * + * @param fileName 用户看到的 UTF-8 文件名 + * @return 文件中心上传接口使用的原始文件名 + */ + private String encodeUploadOriginalFilename(String fileName) { + return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); + } + + /** + * 清理异步导出创建的最终临时文件;清理失败只记录告警,不覆盖原始任务结果。 + * + * @param outputPath 待删除的临时文件路径,允许为空 + */ + private void deleteTemporaryExportFile(Path outputPath) { + if (outputPath == null) { + return; + } + try { + Files.deleteIfExists(outputPath); + } catch (Exception exception) { + log.warn("|用户预约查询导出|清理临时文件失败,path:{}", outputPath, exception); + } + } + + /** + * 校验必填条件和前后端状态协议值,防止未知值被静默当成“全部”。 + * + * @param query 待校验的查询条件 + */ + private void validateQuery(HardCanteenUserApptQueryDto query) { + if (query == null || query.getApptUseDateList() == null || query.getApptUseDateList().length != 2 + || query.getApptUseDateList()[0] == null || query.getApptUseDateList()[1] == null) { + throw new IllegalArgumentException("用餐日期范围必须包含开始和结束日期"); + } + LocalDate startDate = query.getApptUseDateList()[0]; + LocalDate endDate = query.getApptUseDateList()[1]; + if (endDate.isBefore(startDate)) { + throw new IllegalArgumentException("用餐结束日期不能早于开始日期"); + } + if (endDate.isAfter(startDate.plusMonths(MAX_QUERY_MONTHS))) { + throw new IllegalArgumentException("用餐日期范围最多查询两个月"); + } + if (StrUtil.isNotBlank(query.getReservationStatus()) + && !RESERVATION_STATUS_VALUES.contains(query.getReservationStatus())) { + throw new IllegalArgumentException("订餐状态不正确"); + } + if (StrUtil.isNotBlank(query.getVerificationStatus()) + && !VERIFICATION_STATUS_VALUES.contains(query.getVerificationStatus())) { + throw new IllegalArgumentException("核销状态不正确"); + } + } + + /** + * 展开查询范围内的全部自然日,开始和结束日期均包含在结果中。 + * + * @param query 已通过日期范围校验的查询条件 + * @return 按日期升序排列的查询日期 + */ + private List getQueryDates(HardCanteenUserApptQueryDto query) { + LocalDate endDate = query.getApptUseDateList()[1]; + List dates = new ArrayList<>(); + LocalDate currentDate = query.getApptUseDateList()[0]; + while (!currentDate.isAfter(endDate)) { + dates.add(currentDate); + if (currentDate.equals(endDate)) { + break; + } + currentDate = currentDate.plusDays(1L); + } + return dates; + } + + /** + * 判断查询是否需要在用户与预约订单关联后再进行筛选。 + * + * @param query 查询条件 + * @return 存在订餐或核销状态条件时返回 true + */ + private boolean hasJoinedStatusFilter(HardCanteenUserApptQueryDto query) { + return StrUtil.isNotBlank(query.getReservationStatus()) || StrUtil.isNotBlank(query.getVerificationStatus()); + } + + /** + * 判断当前筛选结果是否必然存在有效预约订单。 + * + *

已订餐以及待核销、部分核销、已核销都可以先从订单表反查候选用户;未订餐和无需核销属于 + * “订单不存在”条件,仍需以全部权限用户为主表执行左关联。

+ * + * @param query 预约状态和核销状态筛选条件 + * @return 可以先按预约订单收窄用户范围时返回 true + */ + private boolean requiresExistingReservation(HardCanteenUserApptQueryDto query) { + return Objects.equals(query.getReservationStatus(), RESERVATION_STATUS_RESERVED) + || (StrUtil.isNotBlank(query.getVerificationStatus()) + && !Objects.equals(query.getVerificationStatus(), VERIFICATION_STATUS_NONE)); + } + + /** + * 对必然存在预约的列表或导出条件先查询候选用户,并写回独立查询快照。 + * + *

候选 ID 只负责缩小 UPMS 查询范围,姓名、部门和当前操作者的数据权限仍由 UPMS 最终过滤; + * 原查询已指定用户时,订单查询也会先应用该范围,因此不会扩大导出对象。

+ * + * @param query 当前列表或导出流程独占、允许写入候选用户主键的查询快照 + * @return 无需预筛或存在候选用户时返回 true,确定没有匹配预约时返回 false + */ + private boolean narrowQueryToReservationCandidates(HardCanteenUserApptQueryDto query) { + if (!requiresExistingReservation(query)) { + return true; + } + List candidateUserIds = hardOrderMapper.listReservationUserIds( + buildReservationOrderQuery(query, copyList(query.getUserIds()))); + if (candidateUserIds == null || candidateUserIds.isEmpty()) { + return false; + } + query.setUserIds(candidateUserIds.stream().filter(Objects::nonNull).distinct().toList()); + return !query.getUserIds().isEmpty(); + } + + /** + * 获取完整用户导出资料并在关联预约订单后执行状态筛选。 + * + *

与页面列表的轻量用户扫描分离,只有导出需要角色、岗位和账号扩展字段时才调用 UPMS 完整导出分页。

+ * + * @param query 查询条件 + * @param queryDates 查询范围内按升序排列的全部日期 + * @return 携带完整用户主资料和身份资料的预约导出行 + */ + private List listAllFilteredExportRows(HardCanteenUserApptQueryDto query, + List queryDates) { + List users = fetchAllExportUsers(query); + return filterRows(buildRows(users, query, queryDates), query); + } + + /** + * 调用 UPMS 导出分页查询完整用户主资料、角色岗位和身份扩展。 + * + * @param current 当前页码 + * @param size 每页数量 + * @param query 用户筛选条件 + * @return 含准确总数和完整导出资料的用户分页 + */ + private Page fetchExportUserPage(long current, long size, HardCanteenUserApptQueryDto query) { + R> response = remoteUserService.getScopeExportUserPage(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); + } + return response.getData(); + } + + /** + * 将预约查询中的用户筛选条件转换为 UPMS 用户分页条件。 + * + * @param query 用户预约查询条件 + * @return 姓名手机号、身份、部门包含关系和指定用户条件的独立请求对象 + */ + private UserDTO buildUserQuery(HardCanteenUserApptQueryDto query) { + UserDTO userQuery = new UserDTO(); + userQuery.setNameOrPhone(query.getNameOrPhone()); + userQuery.setIdentityTypes(copyList(query.getIdentityTypes())); + userQuery.setStudentInfo(copyStudentInfo(query.getStudentInfo())); + userQuery.setTeacherInfo(copyTeacherInfo(query.getTeacherInfo())); + userQuery.setHasFace(query.getHasFace()); + userQuery.setDeptIds(copyList(query.getDeptIds())); + userQuery.setIsInclude(query.getIsInclude()); + userQuery.setUserIds(copyList(query.getUserIds())); + return userQuery; + } + + /** + * 分页遍历完整用户导出资料,供兼容同步导出使用。 + * + *

管理端默认走异步分批导出;该方法仅保持原同步接口与新二级表头字段一致。

+ * + * @param query 用户筛选条件 + * @return 权限范围内携带角色岗位和身份资料的全部用户 + */ + private List fetchAllExportUsers(HardCanteenUserApptQueryDto query) { + List users = new ArrayList<>(); + long current = 1L; + long total; + do { + Page page = fetchExportUserPage(current, USER_BATCH_SIZE, query); + List 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 users 权限用户列表 + * @param query 日期范围、食堂和餐段筛选条件 + * @param queryDates 查询范围内按升序排列的全部日期 + * @return 按用户顺序、日期升序排列的预约行 + */ + private List buildRows(List users, HardCanteenUserApptQueryDto query, + List queryDates) { + if (users == null || users.isEmpty()) { + return List.of(); + } + List userIds = users.stream().map(UserVO::getUserId).filter(Objects::nonNull).distinct().toList(); + List orders = fetchReservationOrders(userIds, query); + return buildRows(users, query, queryDates, orders); + } + + /** + * 使用已加载的预约订单构造用户-日期行,供列表查询和统计查询共用同一行生成逻辑。 + * + * @param users 权限范围内的用户 + * @param query 日期、食堂和餐段筛选条件 + * @param queryDates 查询范围内的自然日 + * @param orders 已按相同筛选条件加载的有效预约订单 + * @return 按用户顺序和日期顺序排列的预约行 + */ + private List buildRows(List users, HardCanteenUserApptQueryDto query, + List queryDates, List orders) { + if (users == null || users.isEmpty()) { + return List.of(); + } + Map merchantNameMap = fetchMerchantNameMap(orders); + Map>> orderMap = orders.stream() + .filter(order -> order.getUserId() != null && order.getApptUseDate() != null) + .collect(Collectors.groupingBy(HardOrderVo::getUserId, LinkedHashMap::new, + Collectors.groupingBy(HardOrderVo::getApptUseDate, LinkedHashMap::new, + Collectors.mapping(order -> toOrderVo(order, merchantNameMap), Collectors.toList())))); + + return users.stream().filter(user -> user.getUserId() != null).flatMap(user -> queryDates.stream().map(date -> { + Map> userOrderMap = + orderMap.getOrDefault(user.getUserId(), Map.of()); + List userOrders = userOrderMap.getOrDefault(date, List.of()); + HardCanteenUserApptVo row = new HardCanteenUserApptVo(); + row.setUserId(user.getUserId()); + row.setUserName(resolveUserName(user)); + row.setUsername(user.getUsername()); + row.setPhone(user.getPhone()); + row.setAvatar(user.getAvatar()); + row.setDeptId(user.getDeptId()); + row.setDeptName(user.getDeptName()); + row.setNickname(user.getNickname()); + row.setGender(user.getGender()); + row.setEmail(user.getEmail()); + row.setCreateTime(user.getCreateTime()); + row.setPostList(user.getPostList()); + row.setRoleList(user.getRoleList()); + row.setStaffingType(user.getStaffingType()); + row.setLockFlag(user.getLockFlag()); + // UPMS 的权限用户分页已批量补齐身份资料,这里直接复制,避免按用户再次远程查询。 + row.setIdentityTypes(user.getIdentityTypes()); + row.setStudentInfo(user.getStudentInfo()); + row.setTeacherInfo(user.getTeacherInfo()); + row.setApptUseDate(date); + row.setReservationStatus(userOrders.isEmpty() + ? RESERVATION_STATUS_NOT_RESERVED : RESERVATION_STATUS_RESERVED); + row.setVerificationStatus(resolveVerificationStatus(userOrders)); + row.setTotalPrice(calculateTotalPrice(userOrders)); + row.setOrders(userOrders); + return row; + })).toList(); + } + + /** + * 按用户主键分批查询指定日期范围内的有效食堂预约订单。 + * + * @param userIds 权限范围内的用户主键 + * @param query 日期范围、食堂和餐段筛选条件 + * @return 当前租户内符合条件的有效预约订单 + */ + private List fetchReservationOrders(List userIds, HardCanteenUserApptQueryDto query) { + if (userIds.isEmpty()) { + return List.of(); + } + List orders = new ArrayList<>(); + for (int start = 0; start < userIds.size(); start += ORDER_USER_BATCH_SIZE) { + int end = Math.min(start + ORDER_USER_BATCH_SIZE, userIds.size()); + HardOrderDto orderQuery = buildReservationOrderQuery(query, + new ArrayList<>(userIds.subList(start, end))); + orders.addAll(hardOrderMapper.scopeList(orderQuery, null)); + } + return orders; + } + + /** + * 分批读取预约订单关联的购物车快照,避免统计请求因用户范围过大而生成超长 IN 条件。 + * + * @param orders 当前筛选口径下的预约订单 + * @return 父购物车主键到商品快照的映射 + */ + private Map> fetchReservationCars(List orders) { + List parentCarIds = orders.stream().map(HardOrderVo::getParentCarId) + .filter(Objects::nonNull).distinct().toList(); + if (parentCarIds.isEmpty()) { + return Map.of(); + } + Map> result = new LinkedHashMap<>(); + for (int start = 0; start < parentCarIds.size(); start += ORDER_USER_BATCH_SIZE) { + int end = Math.min(start + ORDER_USER_BATCH_SIZE, parentCarIds.size()); + hardCarMapper.listByParentCarIds(parentCarIds.subList(start, end)) + .forEach(car -> result.computeIfAbsent(car.getParentCarId(), key -> new ArrayList<>()).add(car)); + } + return result; + } + + /** + * 构造用户预约列表、候选用户预查询和导出明细共用的订单条件。 + * + *

统一复用可保证预查询命中的用户与最终写入 Excel 的有效预约口径一致,避免日期、食堂、餐段、 + * 订单状态或预约关联条件在两次查询间发生偏差。

+ * + * @param query 用户预约页面提交的日期、食堂和餐段筛选条件 + * @param userIds 需要限制的用户主键;为空时查询当前租户内全部候选预约用户 + * @return 可直接交给订单 Mapper 的完整预约条件 + */ + private HardOrderDto buildReservationOrderQuery(HardCanteenUserApptQueryDto query, List userIds) { + HardOrderDto orderQuery = new HardOrderDto(); + orderQuery.setUserIds(userIds); + orderQuery.setMerchantType(MerchantTypeEnum.CANTEEN.getType()); + orderQuery.setMerchantId(query.getMerchantId()); + orderQuery.setHourType(query.getHourType()); + orderQuery.setOrderType(HardOrderTypeEnum.RESERVE.getType()); + orderQuery.setOrderStatusCodes(VALID_RESERVATION_ORDER_STATUSES); + orderQuery.setApptIdOrApptRuleIdNotNull(true); + orderQuery.setApptUseDateTimeList(new java.time.LocalDateTime[]{ + query.getApptUseDateList()[0].atStartOfDay(), + query.getApptUseDateList()[1].atTime(LocalTime.MAX) + }); + return orderQuery; + } + + /** + * 批量补齐订单关联的食堂名称;商户服务异常时保留订单并返回空名称,避免影响预约事实查询。 + * + * @param orders 有效预约订单 + * @return 商户主键到食堂名称的映射 + */ + private Map fetchMerchantNameMap(List orders) { + List merchantIds = orders.stream().map(HardOrderVo::getMerchantId) + .filter(Objects::nonNull).distinct().toList(); + if (merchantIds.isEmpty()) { + return Map.of(); + } + try { + R> response = remoteMchService.getByIds(merchantIds); + if (response == null || !response.isOk() || response.getData() == null) { + log.warn("查询用户预约食堂名称失败,保留预约订单并使用空名称"); + return Map.of(); + } + return response.getData().stream().filter(merchant -> + merchant.getId() != null && StrUtil.isNotBlank(merchant.getName())) + .collect(Collectors.toMap(MchManageVo::getId, MchManageVo::getName, + (left, right) -> left, LinkedHashMap::new)); + } catch (Exception exception) { + log.warn("查询用户预约食堂名称异常,保留预约订单并使用空名称,原因:{}", exception.getMessage()); + return Map.of(); + } + } + + /** + * 将订单查询对象转换为页面所需的最小预约明细。 + * + * @param order 有效预约订单 + * @param merchantNameMap 食堂名称映射 + * @return 预约明细 + */ + private HardCanteenUserApptOrderVo toOrderVo(HardOrderVo order, Map merchantNameMap) { + HardCanteenUserApptOrderVo detail = new HardCanteenUserApptOrderVo(); + detail.setId(order.getId()); + detail.setHourType(order.getHourType()); + detail.setProductDesc(order.getProductDesc()); + detail.setMerchantName(merchantNameMap.get(order.getMerchantId())); + detail.setTotalPrice(order.getTotalPrice()); + detail.setOrderStatus(order.getOrderStatus()); + detail.setUpdateTime(order.getVerificationTime() != null ? order.getVerificationTime() : order.getUpdateTime()); + detail.setUpdateBy(order.getUpdateBy()); + return detail; + } + + /** + * 汇总预约明细的商品总价,历史空金额按零分计算。 + * + * @param orders 同一用户、同一用餐日期和当前查询口径下的有效预约明细 + * @return 商品总价,单位为分;没有预约时返回零 + */ + private long calculateTotalPrice(List orders) { + return orders.stream().map(HardCanteenUserApptOrderVo::getTotalPrice).filter(Objects::nonNull) + .mapToLong(Integer::longValue).sum(); + } + + /** + * 依次按姓名、昵称和登录账号确定页面展示名。 + * + * @param user 用户信息 + * @return 非空展示名;用户资料均未维护时返回“未命名用户” + */ + private String resolveUserName(UserVO user) { + if (StrUtil.isNotBlank(user.getName())) { + return user.getName(); + } + if (StrUtil.isNotBlank(user.getNickname())) { + return user.getNickname(); + } + return StrUtil.isBlank(user.getUsername()) ? "未命名用户" : user.getUsername(); + } + + /** + * 根据有效预约明细计算用户整体核销状态。 + * + * @param orders 用户在当前筛选口径下的有效预约明细 + * @return NONE、UNVERIFIED、PARTIAL 或 VERIFIED + */ + private String resolveVerificationStatus(List orders) { + if (orders.isEmpty()) { + return VERIFICATION_STATUS_NONE; + } + long verifiedCount = orders.stream().filter(order -> + Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.CANCEL.getType())).count(); + if (verifiedCount == 0L) { + return VERIFICATION_STATUS_UNVERIFIED; + } + return verifiedCount == orders.size() ? VERIFICATION_STATUS_VERIFIED : VERIFICATION_STATUS_PARTIAL; + } + + /** + * 按订餐和核销状态筛选已完成左关联的用户行。 + * + * @param rows 用户预约行 + * @param query 状态筛选条件 + * @return 符合状态条件的行 + */ + private List filterRows(List rows, + HardCanteenUserApptQueryDto query) { + return rows.stream().filter(row -> + (StrUtil.isBlank(query.getReservationStatus()) + || Objects.equals(query.getReservationStatus(), row.getReservationStatus())) + && (StrUtil.isBlank(query.getVerificationStatus()) + || Objects.equals(query.getVerificationStatus(), row.getVerificationStatus()))) + .toList(); + } + + /** + * 对关联状态筛选后的用户行执行内存分页。 + * + * @param current 当前页码 + * @param size 每页数量 + * @param rows 全部筛选结果 + * @return 标准 MyBatis-Plus 分页结果 + */ + private Page paginateRows(long current, long size, + List rows) { + long offset = (current - 1L) * size; + int fromIndex = offset >= rows.size() ? rows.size() : Math.toIntExact(offset); + long endOffset = Math.min(offset + size, rows.size()); + int toIndex = Math.max(fromIndex, Math.toIntExact(endOffset)); + Page result = new Page<>(current, size, rows.size()); + result.setRecords(rows.subList(fromIndex, toIndex)); + return result; + } + + /** + * 将用户预约汇总转换为单行 Excel 数据,保持“一个用户 + 一个日期”导出口径。 + * + * @param row 用户预约汇总 + * @param query 原始查询条件,用于无订单时保留所选餐段 + * @param mealSegmentLabelMap 餐段编码到字典标签的映射 + * @return Excel 导出行 + */ + private HardCanteenUserApptExcelVo toExcelRow(HardCanteenUserApptVo row, + HardCanteenUserApptQueryDto query, + Map mealSegmentLabelMap) { + List orders = row.getOrders(); + List verifiedOrders = orders.stream().filter(order -> + Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.CANCEL.getType())).toList(); + HardCanteenUserApptExcelVo excel = new HardCanteenUserApptExcelVo(); + excel.setUserId(row.getUserId()); + excel.setUserName(row.getUserName()); + excel.setEmail(row.getEmail()); + excel.setPhone(row.getPhone()); + excel.setDeptName(row.getDeptName()); + excel.setPostNames(joinPostNames(row.getPostList())); + excel.setIdentityTypeNames(joinIdentityNames(row.getIdentityTypes())); + if (row.getStudentInfo() != null) { + excel.setStudentNo(row.getStudentInfo().getStudentNo()); + excel.setSchoolName(row.getStudentInfo().getSchoolName()); + excel.setClassName(row.getStudentInfo().getClassName()); + excel.setGuardianPhones(row.getStudentInfo().getGuardianPhones()); + } + if (row.getTeacherInfo() != null) { + excel.setTeacherNo(row.getTeacherInfo().getTeacherNo()); + } + excel.setApptUseDate(row.getApptUseDate()); + excel.setReservationStatus(Objects.equals(row.getReservationStatus(), RESERVATION_STATUS_RESERVED) + ? "已订餐" : "未订餐"); + excel.setHourTypes(orders.isEmpty() + ? (StrUtil.isBlank(query.getHourType()) ? "全部餐段" + : resolveMealSegmentLabel(query.getHourType(), mealSegmentLabelMap)) + : joinDistinct(orders, order -> resolveMealSegmentLabel(order.getHourType(), + mealSegmentLabelMap))); + excel.setBreakfastReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.MORNING)); + excel.setLunchReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.NOONING)); + excel.setDinnerReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.NIGHT)); + excel.setLateNightReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.LATE_NIGHT)); + excel.setMealSegmentTotalPrices(buildMealSegmentTotalPrices(orders, query, mealSegmentLabelMap)); + excel.setTotalPrice(centsToYuan(row.getTotalPrice())); + excel.setProductDescriptions(joinDistinct(orders, order -> + StrUtil.isBlank(order.getProductDesc()) ? "订餐内容未记录" : order.getProductDesc())); + excel.setMerchantNames(joinDistinct(orders, HardCanteenUserApptOrderVo::getMerchantName)); + excel.setVerificationStatus(verificationStatusText(row.getVerificationStatus())); + excel.setVerificationTimes(joinDistinct(verifiedOrders, order -> order.getUpdateTime() == null + ? "核销时间未记录" : EXCEL_DATE_TIME_FORMATTER.format(order.getUpdateTime()))); + return excel; + } + + /** + * 生成用户预约导出文件末尾的金额合计行。 + * + *

合计仍使用与明细相同的导出模型,姓名列标记“合计”,餐段金额和当日金额分别写入对应统计列, + * 其余人员及预约字段保持为空。

+ * + * @param summary 已累计全部导出分片的金额汇总 + * @param mealSegmentLabelMap 餐段编码到字典标签的映射 + * @return 可直接追加到 EasyExcel 工作表末尾的合计行 + */ + private HardCanteenUserApptExcelVo toExcelSummaryRow(UserReservationExportSummary summary, + Map mealSegmentLabelMap) { + HardCanteenUserApptExcelVo excel = new HardCanteenUserApptExcelVo(); + excel.setUserName("合计"); + excel.setMealSegmentTotalPrices(buildExportSummaryMealSegmentTotalPrices(summary, + mealSegmentLabelMap)); + excel.setTotalPrice(centsToYuan(summary.totalPrice)); + return excel; + } + + /** + * 查询用餐时间段字典并保留字典排序;远程调用失败时由实际订单编码继续完成导出。 + * + * @return 餐段编码到展示标签的有序映射 + */ + private Map fetchMealSegmentLabelMap() { + try { + R> response = remoteDictService.getDictByType(MEAL_SEGMENT_DICT_TYPE); + if (response == null || !response.isOk() || response.getData() == null) { + log.warn("查询用餐时间段字典失败,导出将使用订单餐段编码"); + return Map.of(); + } + return response.getData().stream().filter(item -> + StrUtil.isNotBlank(item.getItemValue()) && StrUtil.isNotBlank(item.getLabel())) + .collect(Collectors.toMap(SysDictItem::getItemValue, SysDictItem::getLabel, + (left, right) -> left, LinkedHashMap::new)); + } catch (Exception exception) { + log.warn("查询用餐时间段字典异常,导出将使用订单餐段编码,原因:{}", exception.getMessage()); + return Map.of(); + } + } + + /** + * 将餐段协议编码转换为导出文案,并兼容历史空编码。 + * + * @param hourType 订单或查询条件中的餐段协议编码 + * @param mealSegmentLabelMap 餐段编码到字典标签的映射 + * @return 字典标签;未知编码保留原值,空编码返回“未标记餐段” + */ + private String resolveMealSegmentLabel(String hourType, Map mealSegmentLabelMap) { + if (StrUtil.isBlank(hourType)) { + return "未标记餐段"; + } + return mealSegmentLabelMap.getOrDefault(hourType, hourType); + } + + /** + * 生成一个固定餐段的预约状态。 + * + * @param orders 用户当天有效预约明细 + * @param query 原始查询条件,用于区分未订餐和未参与查询的餐段 + * @param hourType 固定餐段协议枚举 + * @return “已订N单”“未订餐”或“未查询” + */ + private String buildMealSegmentReservation(List orders, + HardCanteenUserApptQueryDto query, + HardHourTypeEnum hourType) { + if (StrUtil.isNotBlank(query.getHourType()) && !Objects.equals(query.getHourType(), hourType.getType())) { + return "未查询"; + } + long orderCount = orders.stream().filter(order -> + Objects.equals(hourType.getType(), order.getHourType())).count(); + return orderCount == 0L ? "未订餐" : "已订" + orderCount + "单"; + } + + /** + * 生成各餐段商品总价摘要,未订餐段明确输出零元。 + * + * @param orders 用户当天有效预约明细 + * @param query 原始查询条件,选择餐段时只生成该餐段金额 + * @param mealSegmentLabelMap 餐段编码到字典标签的映射 + * @return 例如“早餐:0.00元;午餐:18.50元”的摘要 + */ + private String buildMealSegmentTotalPrices(List orders, + HardCanteenUserApptQueryDto query, + Map mealSegmentLabelMap) { + Map segments = resolveMealSegments(orders, query, mealSegmentLabelMap); + if (segments.isEmpty()) { + return "全部餐段:0.00元"; + } + return segments.entrySet().stream().map(entry -> { + long totalPrice = orders.stream().filter(order -> + Objects.equals(entry.getKey(), order.getHourType())) + .map(HardCanteenUserApptOrderVo::getTotalPrice).filter(Objects::nonNull) + .mapToLong(Integer::longValue).sum(); + return entry.getValue() + ":" + formatYuan(totalPrice); + }).collect(Collectors.joining(";")); + } + + /** + * 格式化全部导出明细累计后的各餐段金额。 + * + * @param summary 跨分片累计的金额汇总 + * @param mealSegmentLabelMap 餐段编码到字典标签的映射 + * @return 例如“早餐:60.80元;午餐:119.00元”的餐段合计 + */ + private String buildExportSummaryMealSegmentTotalPrices(UserReservationExportSummary summary, + Map mealSegmentLabelMap) { + if (summary.mealSegmentTotalPrices.isEmpty()) { + return "全部餐段:0.00元"; + } + return summary.mealSegmentTotalPrices.entrySet().stream() + .map(entry -> resolveMealSegmentLabel(entry.getKey(), mealSegmentLabelMap) + + ":" + formatYuan(entry.getValue())) + .collect(Collectors.joining(";")); + } + + /** + * 合并当前字典餐段和订单中的历史餐段编码,供预约数量及金额摘要复用。 + * + * @param orders 用户当天有效预约明细 + * @param query 原始查询条件 + * @param mealSegmentLabelMap 餐段编码到字典标签的映射 + * @return 保持字典顺序并补齐历史编码的餐段映射 + */ + private Map resolveMealSegments(List orders, + HardCanteenUserApptQueryDto query, + Map mealSegmentLabelMap) { + Map segments = new LinkedHashMap<>(); + if (StrUtil.isNotBlank(query.getHourType())) { + segments.put(query.getHourType(), + mealSegmentLabelMap.getOrDefault(query.getHourType(), query.getHourType())); + } else { + segments.putAll(mealSegmentLabelMap); + orders.stream().map(HardCanteenUserApptOrderVo::getHourType).filter(StrUtil::isNotBlank) + .forEach(hourType -> segments.putIfAbsent(hourType, hourType)); + } + return segments; + } + + /** + * 提取、去空、去重并连接预约明细字段。 + * + * @param orders 预约明细列表 + * @param mapper 字段提取函数 + * @return 使用中文分号连接的字段值;没有有效值时返回空字符串 + */ + private String joinDistinct(List orders, + Function mapper) { + return orders.stream().map(mapper).filter(StrUtil::isNotBlank).distinct() + .collect(Collectors.joining(";")); + } + + /** + * 将整数分转换为精确的元金额。 + * + * @param price 商品总价,单位为分;为空时按零计算 + * @return 以元为单位且固定两位小数精度的金额 + */ + private BigDecimal centsToYuan(Long price) { + return BigDecimal.valueOf(price == null ? 0L : price, 2); + } + + /** + * 将整数分格式化为带元单位的两位小数文案。 + * + * @param price 商品总价,单位为分 + * @return 例如“18.50元”的金额文案 + */ + private String formatYuan(long price) { + return centsToYuan(price).toPlainString() + "元"; + } + + /** + * 拼接用户岗位名称,空岗位列表输出空字符串。 + * + * @param posts 完整用户导出分页返回的岗位列表 + * @return 以中文逗号分隔的岗位名称 + */ + private String joinPostNames(List posts) { + if (posts == null || posts.isEmpty()) { + return ""; + } + return posts.stream().filter(Objects::nonNull).map(SysPost::getPostName) + .filter(StrUtil::isNotBlank).collect(Collectors.joining(",")); + } + + /** + * 按用户身份协议顺序拼接身份中文名称。 + * + * @param identityTypes UPMS 返回的用户身份协议编码 + * @return 以中文逗号分隔的身份名称;未知历史编码保留原值 + */ + private String joinIdentityNames(List identityTypes) { + if (identityTypes == null || identityTypes.isEmpty()) { + return ""; + } + return identityTypes.stream().map(identityType -> { + UserIdentityTypeEnum identity = UserIdentityTypeEnum.valueOfByType(identityType); + return identity == null ? identityType : identity.getDescription(); + }).filter(StrUtil::isNotBlank).collect(Collectors.joining(",")); + } + + /** + * 将核销状态协议值转换为导出文案。 + * + * @param status 核销状态协议值 + * @return 中文核销状态 + */ + private String verificationStatusText(String status) { + if (Objects.equals(status, VERIFICATION_STATUS_VERIFIED)) { + return "已核销"; + } + if (Objects.equals(status, VERIFICATION_STATUS_PARTIAL)) { + return "部分核销"; + } + if (Objects.equals(status, VERIFICATION_STATUS_UNVERIFIED)) { + return "待核销"; + } + return "无需核销"; + } + + /** + * 用户预约导出跨分片金额汇总器。 + * + *

所有金额均以分为单位累计,生成最终合计行时才统一转换为元,避免分片导出过程中出现精度损失。

+ */ + private static final class UserReservationExportSummary { + + /** 各餐段累计金额,键为订单餐段协议编码,值为商品总价,单位为分。 */ + private final Map mealSegmentTotalPrices = new LinkedHashMap<>(); + + /** 全部导出明细的当日金额累计值,单位为分。 */ + private long totalPrice; + + /** + * 创建跨分片汇总器,并按查询餐段或餐段字典顺序预置零金额。 + * + * @param query 本次导出的餐段筛选条件 + * @param mealSegmentLabelMap 餐段编码到字典标签的有序映射 + */ + private UserReservationExportSummary(HardCanteenUserApptQueryDto query, + Map mealSegmentLabelMap) { + if (StrUtil.isNotBlank(query.getHourType())) { + mealSegmentTotalPrices.put(query.getHourType(), 0L); + } else { + mealSegmentLabelMap.keySet().forEach(hourType -> + mealSegmentTotalPrices.put(hourType, 0L)); + } + } + + /** + * 累计一批已经通过全部查询条件筛选的用户日期明细。 + * + * @param rows 当前分片即将写入 Excel 的用户日期明细 + */ + private void addRows(List rows) { + rows.forEach(this::addRow); + } + + /** + * 累计单个用户日期明细的当日总金额和各餐段金额。 + * + * @param row 当前导出的一条用户日期明细 + */ + private void addRow(HardCanteenUserApptVo row) { + totalPrice = Math.addExact(totalPrice, row.getTotalPrice() == null ? 0L : row.getTotalPrice()); + if (row.getOrders() == null || row.getOrders().isEmpty()) { + return; + } + row.getOrders().stream().filter(Objects::nonNull) + .filter(order -> StrUtil.isNotBlank(order.getHourType())) + .forEach(order -> mealSegmentTotalPrices.merge(order.getHourType(), + order.getTotalPrice() == null ? 0L : order.getTotalPrice().longValue(), + Math::addExact)); + } + } + + /** + * 提交菜品预约异步导出任务。 + * + * @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 productReservationExport(HardOrderDto query) { + HardOrderDto querySnapshot = copyProductQuery(query); + List 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 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 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 buildProductRows(HardOrderDto query, LocalDate fixedDate) { + List summaries = hardCarMapper.listApptExportSummary(query); + List merchants = hardCarMapper.listApptExportMerchant(query); + List products = hardCarMapper.listApptExportProduct(query); + if (summaries == null || summaries.isEmpty()) { + return List.of(); + } + Map merchantNames = fetchMerchantNames(merchants); + Map departmentNames = fetchDepartmentNames(merchants); + Map hourNames = fetchHourNames(); + Map menuMap = fetchDailyMenus(products, fixedDate); + Map> merchantGroups = merchants.stream() + .collect(Collectors.groupingBy(this::summaryGroupKey, LinkedHashMap::new, Collectors.toList())); + Map> productGroups = products.stream() + .collect(Collectors.groupingBy(this::groupKey, LinkedHashMap::new, Collectors.toList())); + List 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 merchantRows = merchantGroups.getOrDefault(summaryKey, List.of()); + Map> detailRowsByMerchant = new LinkedHashMap<>(); + Map detailCountsByMerchant = new HashMap<>(); + Map detailPricesByMerchant = new HashMap<>(); + long summaryProductCount = 0L; + long summaryTotalPrice = 0L; + for (HardCanteenProductApptExportRecord merchant : merchantRows) { + String merchantKey = groupKey(merchant); + List detailRows = productGroups + .getOrDefault(merchantKey, List.of()).stream() + .flatMap(product -> toProductRows(product, + menuMap.get(menuKey(product, fixedDate != null ? fixedDate : date))).stream()) + .toList(); + List 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 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; + } + + /** + * 将商品聚合行按预约当天每日菜谱中的套餐组成展开。 + * + *

组成商品数量按“套餐预约数量 × 套餐内份数”计算;组成商品金额按菜谱单价权重使用最大余数法分摊, + * 从而保证所有组成行金额之和严格等于套餐订单快照金额,并避免极小金额因四舍五入产生负数尾差。

+ * + * @param product 订单购物车商品聚合记录,数量和金额均为套餐商品口径 + * @param menu 与商户、预约日期、餐段和商品 SPU 匹配的每日菜谱;为空时保留原商品行 + * @return 单品原始行,或按套餐组成顺序生成的商品明细行 + */ + private List toProductRows(HardCanteenProductApptExportRecord product, DailyMenuAppVO menu) { + List 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 remainderIndexes = new ArrayList<>(); + List 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; + } + + /** + * 在当前商户部门的商品明细右侧写入按商品名称合并后的统计结果。 + * + *

套餐组成商品可能由多个套餐分别展开成同名明细,因此不能按原套餐 SPU 汇总;这里使用压缩空白后的名称作为键, + * 保留首次出现的展示名称,并使用整数分累计金额,确保统计总数量、总价格与左侧有效明细严格一致。

+ * + * @param detailRows 同一预约日期、餐段、商户和部门下,已经扣减完成退款并完成套餐展开的商品明细 + * @return 独立的商品统计行,顺序与商品名称首次出现的顺序一致 + */ + private List buildProductStatistics( + List detailRows) { + Map 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 fetchDailyMenus(List products, + LocalDate fixedDate) { + Map result = new HashMap<>(); + LocalDate date = fixedDate; + if (date == null) return result; + List 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> 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 fetchMerchantNames(List rows) { + List ids = rows.stream().map(HardCanteenProductApptExportRecord::getMerchantId).filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) return Map.of(); + try { + R> 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 fetchDepartmentNames(List rows) { + List ids = rows.stream().map(HardCanteenProductApptExportRecord::getUserDeptId).filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) return Map.of(); + try { + R> 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 fetchHourNames() { + try { + R> 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 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 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); + } + + /** + * 将商品明细展示金额从元转换回分,用于按最终商品行重新计算日期餐段和部门合计。 + * + *

商品行已经按两位小数输出,因此这里使用半舍入恢复整数分,避免直接使用 double 导致合计出现尾差。

+ * + * @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 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); + } + +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java index 228ebb91..86004770 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java @@ -2,36 +2,29 @@ package com.bsd.pjcloud.hard.service.impl.order; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; -import com.alibaba.excel.EasyExcel; -import com.alibaba.excel.ExcelWriter; -import com.alibaba.excel.write.metadata.WriteSheet; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.bsd.pjcloud.admin.api.constant.user.UserIdentityTypeEnum; import com.bsd.pjcloud.admin.api.dto.UserDTO; -import com.bsd.pjcloud.admin.api.entity.SysDictItem; -import com.bsd.pjcloud.admin.api.entity.SysPost; -import com.bsd.pjcloud.admin.api.feign.RemoteDictService; -import com.bsd.pjcloud.admin.api.feign.RemoteFileService; +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.common.asynctask.service.AsyncTaskExecutorService; -import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter; -import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult; -import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; -import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate; -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.dto.order.HardCanteenUserApptQueryDto; import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; +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.HardCanteenUserApptDailyStatisticsVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptDishStatisticsVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptMealStatisticsVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsSummaryVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsVo; import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; -import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo; import com.bsd.pjcloud.hard.mapper.order.HardCarMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.HardCanteenApptTotalService; @@ -39,20 +32,13 @@ import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.mock.web.MockMultipartFile; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; -import org.springframework.web.multipart.MultipartFile; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; import java.time.LocalDate; -import java.time.LocalDateTime; import java.time.LocalTime; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -78,49 +64,6 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ /** 单次订单查询的用户主键数量,避免 SQL IN 条件过长。 */ private static final int ORDER_USER_BATCH_SIZE = 500; - /** 用户预约导出任务的 Redis Key 命名空间。 */ - private static final String USER_RESERVATION_EXPORT_REDIS_KEY = "hard:canteen-user-appt:export"; - - /** 用户预约导出任务在统一进度弹窗中的展示名称。 */ - private static final String USER_RESERVATION_EXPORT_TASK_NAME = "用户预约查询导出"; - - /** 查询实际权限用户数量前创建异步任务使用的占位总数。 */ - private static final int USER_RESERVATION_EXPORT_INITIAL_TOTAL = 1; - - /** 用户预约导出每批读取的权限用户数,单批最多展开为该人数乘以查询自然日数。 */ - private static final int USER_RESERVATION_EXPORT_BATCH_SIZE = 200; - - /** 文件上传和异步任务成功收尾占用的额外进度步数。 */ - private static final int USER_RESERVATION_EXPORT_EXTRA_PROGRESS_STEPS = 2; - - /** 用户预约导出的二级表头行数,第一行是业务分组,第二行是具体字段。 */ - private static final int USER_RESERVATION_EXPORT_HEADER_ROW_COUNT = 2; - - /** 用户预约导出明细之后固定追加的合计行数量。 */ - private static final int USER_RESERVATION_EXPORT_SUMMARY_ROW_COUNT = 1; - - /** 用户预约导出工作表二级表头之后允许写入的最大明细行数。 */ - private static final int MAX_USER_RESERVATION_EXPORT_ROWS = - BatchExcelExportTemplate.calculateMaxDetailRowCount(USER_RESERVATION_EXPORT_HEADER_ROW_COUNT, - USER_RESERVATION_EXPORT_SUMMARY_ROW_COUNT); - - /** 用户预约导出工作表名称。 */ - private static final String USER_RESERVATION_EXPORT_SHEET_NAME = "用户预约查询"; - - /** 文件中心保存用户预约导出文件的业务目录。 */ - private static final String USER_RESERVATION_EXPORT_FILE_DIR = "hard-export"; - - /** 文件中心区分用户预约导出文件的类型编码。 */ - private static final String USER_RESERVATION_EXPORT_FILE_TYPE = "HARD_CANTEEN_USER_APPT_EXPORT"; - - /** 用户预约 Excel 文件上传时使用的标准 MIME 类型。 */ - private static final String EXCEL_CONTENT_TYPE = - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; - - /** 异步导出文件名使用的时间格式,避免连续任务产生同名文件。 */ - private static final DateTimeFormatter EXPORT_FILE_TIME_FORMATTER = - DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); - /** 用户预约查询允许的最大自然月跨度,开始和结束日期均计入查询范围。 */ private static final long MAX_QUERY_MONTHS = 2L; @@ -154,12 +97,6 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ /** 有效预约订单状态,只统计已支付待核销和已完成核销的订单。 */ private static final List VALID_RESERVATION_ORDER_STATUSES = HardOrderStatusEnum.getPaySuccessCodes(); - /** Excel 中核销时间的统一展示格式。 */ - private static final DateTimeFormatter EXCEL_DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - - /** 用餐时间段字典类型,属于 PC、App 与后端共同使用的既有协议。 */ - private static final String MEAL_SEGMENT_DICT_TYPE = "daily_menu"; - /** 菜品预约聚合 Mapper。 */ private final HardCarMapper hardCarMapper; @@ -169,18 +106,9 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ /** 用户远程服务,负责返回当前操作者数据权限范围内的用户分页。 */ private final RemoteUserService remoteUserService; - /** 字典远程服务,用于在 Excel 中完整列出已配置和未预约的餐段。 */ - private final RemoteDictService remoteDictService; - - /** 商户远程服务,用于批量补齐预约所属食堂名称。 */ + /** 商户远程服务,用于补齐预约所属食堂名称。 */ private final RemoteMchService remoteMchService; - /** 通用异步任务执行器,负责恢复租户、安全及 Feign 上下文并持久化导出进度。 */ - private final AsyncTaskExecutorService asyncTaskExecutorService; - - /** 文件中心远程服务,用于保存异步生成的用户预约 Excel。 */ - private final RemoteFileService remoteFileService; - /** * 按预约使用日期和用餐时间段汇总已支付的预约菜品。 * @@ -264,229 +192,20 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ } /** - * 提交用户每日预约异步导出任务。 + * 复制用户预约查询条件,避免分页查询在预筛选过程中修改调用方持有的可变集合。 * - *

请求线程只校验并复制筛选条件;后台任务恢复当前租户和登录用户上下文后,按权限用户分页读取并 - * 分批写入 Excel,避免长时间占用 HTTP 连接和一次性持有全部导出行。

- * - * @param query 用户、日期范围、食堂、餐段和状态筛选条件 - * @return 已创建任务的初始进度 - */ - @Override - public AsyncTaskProgressVO submitUserReservationExport(HardCanteenUserApptQueryDto query) { - validateQuery(query); - HardCanteenUserApptQueryDto querySnapshot = copyUserReservationQuery(query); - return asyncTaskExecutorService.submitWithResult(USER_RESERVATION_EXPORT_REDIS_KEY, - USER_RESERVATION_EXPORT_TASK_NAME, USER_RESERVATION_EXPORT_INITIAL_TOTAL, - reporter -> exportUserReservations(querySnapshot, reporter)); - } - - /** - * 查询用户预约 Excel 数据。 - * - * @param query 用户、日期范围和预约状态筛选条件 - * @return 用户和日期维度的导出行,最后一行为餐段及当日金额合计 - */ - @Override - public List userReservationExport(HardCanteenUserApptQueryDto query) { - validateQuery(query); - HardCanteenUserApptQueryDto exportQuery = copyUserReservationQuery(query); - if (!narrowQueryToReservationCandidates(exportQuery)) { - return List.of(); - } - Map mealSegmentLabelMap = fetchMealSegmentLabelMap(); - List reservationRows = - listAllFilteredExportRows(exportQuery, getQueryDates(exportQuery)); - if (reservationRows.isEmpty()) { - return List.of(); - } - UserReservationExportSummary summary = - new UserReservationExportSummary(exportQuery, mealSegmentLabelMap); - summary.addRows(reservationRows); - List excelRows = new ArrayList<>(reservationRows.size() + 1); - reservationRows.stream().map(row -> toExcelRow(row, exportQuery, mealSegmentLabelMap)) - .forEach(excelRows::add); - excelRows.add(toExcelSummaryRow(summary, mealSegmentLabelMap)); - return excelRows; - } - - /** - * 在异步线程中分批查询用户预约、生成 Excel 并上传文件中心。 - * - * @param query 后台线程独占的用户预约筛选条件 - * @param reporter 当前异步任务进度报告器 - * @return 文件中心下载地址和业务文件名 - */ - private AsyncTaskResult exportUserReservations(HardCanteenUserApptQueryDto query, - AsyncTaskProgressReporter reporter) { - if (!narrowQueryToReservationCandidates(query)) { - throw new IllegalStateException("没有符合当前筛选条件的用户预约记录"); - } - Page firstUserPage = fetchExportUserPage(1L, USER_RESERVATION_EXPORT_BATCH_SIZE, query); - long userTotal = firstUserPage.getTotal(); - if (userTotal <= 0L) { - throw new IllegalStateException("没有符合当前条件的用户预约数据"); - } - validateUserReservationExportRowCapacity(userTotal, query); - int progressTotal = calculateUserReservationExportProgressTotal(userTotal); - reporter.updateTotal(progressTotal, "已确定 " + userTotal + " 位符合条件用户,将分批生成预约文件"); - - String fileName = "用户预约查询_" + LocalDateTime.now().format(EXPORT_FILE_TIME_FORMATTER) + ".xlsx"; - Path outputPath = null; - try { - outputPath = Files.createTempFile("canteen-user-appt-export-", ".xlsx"); - long exportedRowTotal = writeUserReservationWorkbook(outputPath, query, firstUserPage, userTotal, - reporter); - String fileUrl = uploadUserReservationWorkbook(outputPath, fileName); - reporter.advance("用户预约导出文件已生成,共 " + exportedRowTotal + " 条"); - return AsyncTaskResult.file(fileUrl, fileName); - } catch (IllegalStateException exception) { - throw exception; - } catch (Exception exception) { - log.error("|用户预约查询导出|生成或保存文件失败,fileName:{}", fileName, exception); - throw new IllegalStateException("生成用户预约导出文件失败", exception); - } finally { - deleteTemporaryExportFile(outputPath); - } - } - - /** - * 按权限用户分页读取并把当前批次结果写入 EasyExcel 流式工作簿。 - * - *

订餐和核销状态依赖订单关联结果,因此在每批用户展开日期后执行;批次写入完成即可释放, - * 不会把整个日期范围的全部用户行同时留在 JVM 堆中。

- * - * @param outputPath 最终上传前使用的临时 XLSX 文件 - * @param query 已冻结的筛选条件 - * @param firstUserPage 已用于取得用户总数的第一页,避免重复远程调用 - * @param userTotal 任务开始时取得的权限用户总数 - * @param reporter 当前异步任务进度报告器 - * @return 实际写入 Excel 的用户日期行数 - */ - private long writeUserReservationWorkbook(Path outputPath, HardCanteenUserApptQueryDto query, - Page firstUserPage, long userTotal, - AsyncTaskProgressReporter reporter) { - List queryDates = getQueryDates(query); - Map mealSegmentLabelMap = fetchMealSegmentLabelMap(); - long processedUserTotal = 0L; - long exportedRowTotal = 0L; - long current = 1L; - UserReservationExportSummary summary = new UserReservationExportSummary(query, mealSegmentLabelMap); - - try (ExcelWriter excelWriter = EasyExcel.write(outputPath.toFile(), HardCanteenUserApptExcelVo.class).build()) { - WriteSheet writeSheet = EasyExcel.writerSheet(USER_RESERVATION_EXPORT_SHEET_NAME).build(); - Page userPage = firstUserPage; - while (processedUserTotal < userTotal) { - List pageRecords = userPage.getRecords() == null ? List.of() : userPage.getRecords(); - if (pageRecords.isEmpty()) { - break; - } - int remainingUserCount = Math.toIntExact(Math.min(pageRecords.size(), userTotal - processedUserTotal)); - List batchUsers = pageRecords.subList(0, remainingUserCount); - List batchReservationRows = - filterRows(buildRows(batchUsers, query, queryDates), query); - long nextExportedRowTotal = exportedRowTotal + batchReservationRows.size(); - if (nextExportedRowTotal > MAX_USER_RESERVATION_EXPORT_ROWS) { - throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 " - + MAX_USER_RESERVATION_EXPORT_ROWS + " 条,请缩小查询范围后分批导出"); - } - List batchRows = batchReservationRows.stream() - .map(row -> toExcelRow(row, query, mealSegmentLabelMap)).toList(); - if (!batchRows.isEmpty()) { - excelWriter.write(batchRows, writeSheet); - summary.addRows(batchReservationRows); - } - exportedRowTotal = nextExportedRowTotal; - processedUserTotal += batchUsers.size(); - reporter.advance("已处理 " + processedUserTotal + " / " + userTotal + " 位用户,已写入 " - + exportedRowTotal + " 条预约记录"); - if (processedUserTotal >= userTotal) { - break; - } - userPage = fetchExportUserPage(++current, USER_RESERVATION_EXPORT_BATCH_SIZE, query); - } - if (processedUserTotal != userTotal) { - throw new IllegalStateException("导出用户数据在执行期间发生变化,预期 " + userTotal - + " 位,实际处理 " + processedUserTotal + " 位"); - } - if (exportedRowTotal == 0L) { - throw new IllegalStateException("没有符合当前筛选条件的用户预约记录"); - } - excelWriter.write(List.of(toExcelSummaryRow(summary, mealSegmentLabelMap)), writeSheet); - } - return exportedRowTotal; - } - - /** - * 根据权限用户总数计算“用户查询批次数 + 文件上传和任务收尾”的进度总步数。 - * - * @param userTotal 当前筛选条件下的权限用户总数 - * @return 统一进度组件使用的总步数 - */ - private int calculateUserReservationExportProgressTotal(long userTotal) { - return BatchExcelExportTemplate.calculateProgressTotal(userTotal, USER_RESERVATION_EXPORT_BATCH_SIZE, - USER_RESERVATION_EXPORT_EXTRA_PROGRESS_STEPS); - } - - /** - * 在没有订餐或核销状态筛选时,提前校验确定会生成的“用户 × 日期”明细行数。 - * - *

存在关联状态筛选时最终行数只能在分批关联订单后确定,写入过程仍会执行单工作表上限保护。

- * - * @param userTotal 当前用户筛选和数据权限范围内的用户总数 - * @param query 已冻结的日期和预约状态筛选条件 - */ - private void validateUserReservationExportRowCapacity(long userTotal, HardCanteenUserApptQueryDto query) { - if (hasJoinedStatusFilter(query)) { - return; - } - long rowTotal; - try { - rowTotal = Math.multiplyExact(userTotal, (long) getQueryDates(query).size()); - } catch (ArithmeticException exception) { - throw new IllegalStateException("用户预约导出数据量过大,请缩小查询范围后分批导出", exception); - } - if (rowTotal > MAX_USER_RESERVATION_EXPORT_ROWS) { - throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 " - + MAX_USER_RESERVATION_EXPORT_ROWS + " 条,请缩小查询范围后分批导出"); - } - } - - /** - * 将已关闭的完整 XLSX 临时文件上传到统一文件中心。 - * - * @param outputPath 已完成写入的临时文件 - * @param fileName 用户下载时使用的业务文件名 - * @return 文件中心提供的下载地址 - * @throws Exception 临时文件读取或远程上传失败时抛出 - */ - private String uploadUserReservationWorkbook(Path outputPath, String fileName) throws Exception { - MultipartFile exportFile = new MockMultipartFile("file", encodeUploadOriginalFilename(fileName), - EXCEL_CONTENT_TYPE, Files.readAllBytes(outputPath)); - R uploadResult = remoteFileService.innerUpload(exportFile, USER_RESERVATION_EXPORT_FILE_DIR, null, - USER_RESERVATION_EXPORT_FILE_TYPE); - if (uploadResult == null || !uploadResult.isOk() - || !(uploadResult.getData() instanceof Map resultData)) { - throw new IllegalStateException("文件中心未返回有效的用户预约导出结果"); - } - Object resultUrl = resultData.get("url"); - if (resultUrl == null || StrUtil.isBlank(resultUrl.toString())) { - throw new IllegalStateException("文件中心未返回用户预约导出下载地址"); - } - return resultUrl.toString(); - } - - /** - * 复制用户预约查询条件,避免异步任务与控制器请求对象共享可变数组或列表。 - * - * @param source 已通过基础校验的请求筛选条件 - * @return 后台线程独占的完整筛选条件副本 + * @param source 原始用户预约筛选条件 + * @return 与原条件值等价且集合独立的查询副本 */ private HardCanteenUserApptQueryDto copyUserReservationQuery(HardCanteenUserApptQueryDto source) { HardCanteenUserApptQueryDto target = new HardCanteenUserApptQueryDto(); target.setApptUseDateList(source.getApptUseDateList() == null ? null : source.getApptUseDateList().clone()); target.setNameOrPhone(source.getNameOrPhone()); + target.setIdentityTypes(copyList(source.getIdentityTypes())); + target.setStudentInfo(copyStudentInfo(source.getStudentInfo())); + target.setTeacherInfo(copyTeacherInfo(source.getTeacherInfo())); + target.setHasFace(source.getHasFace()); target.setDeptIds(copyList(source.getDeptIds())); target.setIsInclude(source.getIsInclude()); target.setMerchantId(source.getMerchantId()); @@ -498,40 +217,83 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ } /** - * 复制可能为空的筛选列表,保留空值与空集合的原始查询语义。 + * 复制可选用户筛选列表,保留空值语义并避免远程请求对象之间共享可变列表。 * - * @param source 请求对象中的可变列表 + * @param source 原始列表,可为空 * @param 列表元素类型 - * @return 空值保持为空,非空列表返回独立副本 + * @return 独立列表副本或空值 */ private List copyList(List source) { return source == null ? null : new ArrayList<>(source); } /** - * 按文件服务约定编码中文原始文件名,避免 ISO-8859-1 到 UTF-8 的转换产生乱码。 + * 复制学生身份查询条件,避免分页预筛选改动请求对象持有的可变扩展资料。 * - * @param fileName 用户看到的 UTF-8 文件名 - * @return 文件中心上传接口使用的原始文件名 + * @param source 原始学生身份查询条件 + * @return 独立的学生身份查询条件;源值为空时返回空值 */ - private String encodeUploadOriginalFilename(String fileName) { - return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); + 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 outputPath 待删除的临时文件路径,允许为空 + * @param source 原始老师身份查询条件 + * @return 独立的老师身份查询条件;源值为空时返回空值 */ - private void deleteTemporaryExportFile(Path outputPath) { - if (outputPath == null) { - return; - } - try { - Files.deleteIfExists(outputPath); - } catch (Exception exception) { - log.warn("|用户预约查询导出|清理临时文件失败,path:{}", outputPath, exception); + private SysUserTeacherDto copyTeacherInfo(SysUserTeacherDto source) { + if (source == null) return null; + SysUserTeacherDto target = new SysUserTeacherDto(); + target.setTeacherNo(source.getTeacherNo()); + return target; + } + + /** + * 按当前用户预约查询条件生成每日和查询范围总计。 + * + *

统计先复用用户预约列表的权限用户、有效订单和状态筛选口径,再读取订单购物车快照计算菜品份数, + * 保证抽屉中的人数、订单和菜品不会因为分页而少算或多算。

+ * + * @param query 用户、身份、日期范围、食堂、餐段和状态筛选条件 + * @return 按日期升序排列并包含范围总计的统计结果 + */ + @Override + public HardCanteenUserApptStatisticsVo userReservationStatistics(HardCanteenUserApptQueryDto query) { + validateQuery(query); + List queryDates = getQueryDates(query); + List users = fetchAllUsers(query); + List userIds = users.stream().map(UserVO::getUserId).filter(Objects::nonNull).distinct().toList(); + List orders = fetchReservationOrders(userIds, query); + List rows = filterRows(buildRows(users, query, queryDates, orders), query); + Map orderMap = orders.stream().filter(order -> order.getId() != null) + .collect(Collectors.toMap(HardOrderVo::getId, Function.identity(), (left, right) -> left)); + Map> carMap = fetchReservationCars(orders); + + Map dailyBuckets = new LinkedHashMap<>(); + queryDates.forEach(date -> dailyBuckets.put(date, new StatisticsBucket())); + StatisticsBucket totalBucket = new StatisticsBucket(); + for (HardCanteenUserApptVo row : rows) { + StatisticsBucket dailyBucket = dailyBuckets.get(row.getApptUseDate()); + if (dailyBucket == null) { + continue; + } + dailyBucket.addRow(row, orderMap, carMap); + totalBucket.addRow(row, orderMap, carMap); } + + HardCanteenUserApptStatisticsVo result = new HardCanteenUserApptStatisticsVo(); + result.setTotal(totalBucket.toSummary()); + result.setDaily(queryDates.stream().map(date -> dailyBuckets.get(date).toDaily(date)).toList()); + return result; } /** @@ -642,20 +404,6 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ return filterRows(buildRows(users, query, queryDates), query); } - /** - * 获取完整用户导出资料并在关联预约订单后执行状态筛选。 - * - *

与页面列表的轻量用户扫描分离,只有导出需要角色、岗位和账号扩展字段时才调用 UPMS 完整导出分页。

- * - * @param query 查询条件 - * @param queryDates 查询范围内按升序排列的全部日期 - * @return 携带完整用户主资料和身份资料的预约导出行 - */ - private List listAllFilteredExportRows(HardCanteenUserApptQueryDto query, - List queryDates) { - List users = fetchAllExportUsers(query); - return filterRows(buildRows(users, query, queryDates), query); - } /** * 在没有关联状态筛选时,按“用户在前、日期在后”的稳定顺序查询当前结果页。 @@ -797,33 +545,20 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ return response.getData(); } - /** - * 调用 UPMS 导出分页查询完整用户主资料、角色岗位和身份扩展。 - * - * @param current 当前页码 - * @param size 每页数量 - * @param query 用户筛选条件 - * @return 含准确总数和完整导出资料的用户分页 - */ - private Page fetchExportUserPage(long current, long size, HardCanteenUserApptQueryDto query) { - R> response = remoteUserService.getScopeExportUserPage(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); - } - return response.getData(); - } /** * 将预约查询中的用户筛选条件转换为 UPMS 用户分页条件。 * * @param query 用户预约查询条件 - * @return 姓名手机号、部门包含关系和指定用户条件的独立请求对象 + * @return 姓名手机号、身份、部门包含关系和指定用户条件的独立请求对象 */ private UserDTO buildUserQuery(HardCanteenUserApptQueryDto query) { UserDTO userQuery = new UserDTO(); userQuery.setNameOrPhone(query.getNameOrPhone()); + userQuery.setIdentityTypes(copyList(query.getIdentityTypes())); + userQuery.setStudentInfo(copyStudentInfo(query.getStudentInfo())); + userQuery.setTeacherInfo(copyTeacherInfo(query.getTeacherInfo())); + userQuery.setHasFace(query.getHasFace()); userQuery.setDeptIds(copyList(query.getDeptIds())); userQuery.setIsInclude(query.getIsInclude()); userQuery.setUserIds(copyList(query.getUserIds())); @@ -853,30 +588,6 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ return users; } - /** - * 分页遍历完整用户导出资料,供兼容同步导出使用。 - * - *

管理端默认走异步分批导出;该方法仅保持原同步接口与新二级表头字段一致。

- * - * @param query 用户筛选条件 - * @return 权限范围内携带角色岗位和身份资料的全部用户 - */ - private List fetchAllExportUsers(HardCanteenUserApptQueryDto query) { - List users = new ArrayList<>(); - long current = 1L; - long total; - do { - Page page = fetchExportUserPage(current, USER_BATCH_SIZE, query); - List 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; - } /** * 将用户列表与日期范围内的预约订单按用户、日期左关联,每个用户每天至少生成一行。 @@ -893,6 +604,23 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ } List userIds = users.stream().map(UserVO::getUserId).filter(Objects::nonNull).distinct().toList(); List orders = fetchReservationOrders(userIds, query); + return buildRows(users, query, queryDates, orders); + } + + /** + * 使用已加载的预约订单构造用户-日期行,供列表查询和统计查询共用同一行生成逻辑。 + * + * @param users 权限范围内的用户 + * @param query 日期、食堂和餐段筛选条件 + * @param queryDates 查询范围内的自然日 + * @param orders 已按相同筛选条件加载的有效预约订单 + * @return 按用户顺序和日期顺序排列的预约行 + */ + private List buildRows(List users, HardCanteenUserApptQueryDto query, + List queryDates, List orders) { + if (users == null || users.isEmpty()) { + return List.of(); + } Map merchantNameMap = fetchMerchantNameMap(orders); Map>> orderMap = orders.stream() .filter(order -> order.getUserId() != null && order.getApptUseDate() != null) @@ -955,6 +683,27 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ return orders; } + /** + * 分批读取预约订单关联的购物车快照,避免统计请求因用户范围过大而生成超长 IN 条件。 + * + * @param orders 当前筛选口径下的预约订单 + * @return 父购物车主键到商品快照的映射 + */ + private Map> fetchReservationCars(List orders) { + List parentCarIds = orders.stream().map(HardOrderVo::getParentCarId) + .filter(Objects::nonNull).distinct().toList(); + if (parentCarIds.isEmpty()) { + return Map.of(); + } + Map> result = new LinkedHashMap<>(); + for (int start = 0; start < parentCarIds.size(); start += ORDER_USER_BATCH_SIZE) { + int end = Math.min(start + ORDER_USER_BATCH_SIZE, parentCarIds.size()); + hardCarMapper.listByParentCarIds(parentCarIds.subList(start, end)) + .forEach(car -> result.computeIfAbsent(car.getParentCarId(), key -> new ArrayList<>()).add(car)); + } + return result; + } + /** * 构造用户预约列表、候选用户预查询和导出明细共用的订单条件。 * @@ -1024,11 +773,231 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ detail.setMerchantName(merchantNameMap.get(order.getMerchantId())); detail.setTotalPrice(order.getTotalPrice()); detail.setOrderStatus(order.getOrderStatus()); - detail.setUpdateTime(order.getUpdateTime()); + detail.setUpdateTime(order.getVerificationTime() != null ? order.getVerificationTime() : order.getUpdateTime()); detail.setUpdateBy(order.getUpdateBy()); return detail; } + /** + * 统计查询的内存聚合器,按用户日期、餐段和菜品三个层级累计指标。 + */ + private static final class StatisticsBucket { + + /** 用户-日期行总数。 */ + private long userCount; + + /** 已订餐用户-日期行数。 */ + private long reservedUserCount; + + /** 未订餐用户-日期行数。 */ + private long unreservedUserCount; + + /** 有效预约订单数。 */ + private long orderCount; + + /** 菜品总份数。 */ + private long dishCount; + + /** 预约总价,单位为分。 */ + private long totalPrice; + + /** 去重用户主键集合。 */ + private final Set uniqueUserIds = new HashSet<>(); + + /** 餐段编码到餐段聚合结果的有序映射。 */ + private final Map meals = new LinkedHashMap<>(); + + /** 餐段和菜品名称到菜品聚合结果的有序映射。 */ + private final Map dishes = new LinkedHashMap<>(); + + /** + * 累加一条用户-日期预约行及其订单商品快照。 + * + * @param row 用户-日期预约行 + * @param orderMap 订单主键到订单对象的映射 + * @param carMap 父购物车主键到商品快照的映射 + */ + private void addRow(HardCanteenUserApptVo row, Map orderMap, + Map> carMap) { + userCount++; + if (row.getUserId() != null) { + uniqueUserIds.add(row.getUserId()); + } + List rowOrders = row.getOrders() == null ? List.of() : row.getOrders(); + if (rowOrders.isEmpty()) { + unreservedUserCount++; + return; + } + reservedUserCount++; + Set rowOrderIds = new HashSet<>(); + for (HardCanteenUserApptOrderVo rowOrder : rowOrders) { + Long orderId = rowOrder.getId(); + HardOrderVo order = orderId == null ? null : orderMap.get(orderId); + if (orderId != null && rowOrderIds.add(orderId)) { + orderCount++; + totalPrice += number(rowOrder.getTotalPrice()); + } + String hourType = normalize(rowOrder.getHourType(), "未标记餐段"); + MealStatisticsAccumulator meal = meals.computeIfAbsent(hourType, + key -> new MealStatisticsAccumulator(key)); + if (orderId != null && meal.orderIds.add(orderId)) { + meal.reservedUserDateKeys.add(row.getUserId() + "_" + row.getApptUseDate()); + meal.orderCount++; + meal.totalPrice += number(rowOrder.getTotalPrice()); + } + List cars = order == null ? List.of() : carMap.getOrDefault(order.getParentCarId(), List.of()); + for (HardCarEntity car : cars) { + long count = number(car.getProductCount()); + dishCount += count; + meal.dishCount += count; + String productName = normalize(car.getProductName(), "未命名菜品"); + String dishKey = hourType + "\u0000" + productName; + DishStatisticsAccumulator dish = dishes.computeIfAbsent(dishKey, + key -> new DishStatisticsAccumulator(hourType, productName)); + dish.productCount += count; + dish.totalPrice += number(car.getTotalPrice()); + if (orderId != null) { + dish.orderIds.add(orderId); + } + } + } + } + + /** + * 转换为总计汇总响应。 + * + * @return 总计统计数据 + */ + private HardCanteenUserApptStatisticsSummaryVo toSummary() { + HardCanteenUserApptStatisticsSummaryVo summary = new HardCanteenUserApptStatisticsSummaryVo(); + summary.setUserCount(userCount); + summary.setUniqueUserCount((long) uniqueUserIds.size()); + summary.setReservedUserCount(reservedUserCount); + summary.setUnreservedUserCount(unreservedUserCount); + summary.setOrderCount(orderCount); + summary.setDishCount(dishCount); + summary.setTotalPrice(totalPrice); + summary.setMeals(meals.values().stream().map(MealStatisticsAccumulator::toVo).toList()); + summary.setDishes(dishes.values().stream().map(DishStatisticsAccumulator::toVo).toList()); + return summary; + } + + /** + * 转换为每日统计响应。 + * + * @param date 用餐日期 + * @return 每日统计数据 + */ + private HardCanteenUserApptDailyStatisticsVo toDaily(LocalDate date) { + HardCanteenUserApptDailyStatisticsVo daily = new HardCanteenUserApptDailyStatisticsVo(); + daily.setDate(date); + HardCanteenUserApptStatisticsSummaryVo summary = toSummary(); + daily.setUserCount(summary.getUserCount()); + daily.setUniqueUserCount(summary.getUniqueUserCount()); + daily.setReservedUserCount(summary.getReservedUserCount()); + daily.setUnreservedUserCount(summary.getUnreservedUserCount()); + daily.setOrderCount(summary.getOrderCount()); + daily.setDishCount(summary.getDishCount()); + daily.setTotalPrice(summary.getTotalPrice()); + daily.setMeals(summary.getMeals()); + daily.setDishes(summary.getDishes()); + return daily; + } + + /** 将可能为空的数值安全归一为零。 */ + private static long number(Number value) { + return value == null ? 0L : value.longValue(); + } + + /** 将可能为空的名称安全归一为统计展示文案。 */ + private static String normalize(String value, String fallback) { + return StrUtil.isBlank(value) ? fallback : value; + } + } + + /** 餐段统计的可变聚合状态。 */ + private static final class MealStatisticsAccumulator { + + /** 餐段编码或兼容展示文案。 */ + private final String hourType; + + /** 餐段内去重的用户-用餐日期键,范围总计按订餐人次累计并可与每日结果相加。 */ + private final Set reservedUserDateKeys = new HashSet<>(); + + /** 餐段内去重的订单主键。 */ + private final Set orderIds = new HashSet<>(); + + /** 餐段菜品份数。 */ + private long dishCount; + + /** 餐段预约订单数。 */ + private long orderCount; + + /** 餐段预约总价,单位为分。 */ + private long totalPrice; + + /** + * 创建指定餐段的聚合状态。 + * + * @param hourType 餐段协议编码或历史兼容文案 + */ + private MealStatisticsAccumulator(String hourType) { + this.hourType = hourType; + } + + /** 转换为餐段统计响应。 */ + private HardCanteenUserApptMealStatisticsVo toVo() { + HardCanteenUserApptMealStatisticsVo vo = new HardCanteenUserApptMealStatisticsVo(); + vo.setHourType(hourType); + vo.setReservedUserCount((long) reservedUserDateKeys.size()); + vo.setOrderCount(orderCount); + vo.setDishCount(dishCount); + vo.setTotalPrice(totalPrice); + return vo; + } + } + + /** 菜品统计的可变聚合状态。 */ + private static final class DishStatisticsAccumulator { + + /** 餐段编码或兼容展示文案。 */ + private final String hourType; + + /** 下单时固化的菜品名称。 */ + private final String productName; + + /** 菜品快照份数。 */ + private long productCount; + + /** 菜品快照总价,单位为分。 */ + private long totalPrice; + + /** 包含该菜品的去重订单主键。 */ + private final Set orderIds = new HashSet<>(); + + /** + * 创建指定餐段和菜品名称的聚合状态。 + * + * @param hourType 餐段协议编码或历史兼容文案 + * @param productName 下单时保存的菜品名称 + */ + private DishStatisticsAccumulator(String hourType, String productName) { + this.hourType = hourType; + this.productName = productName; + } + + /** 转换为菜品统计响应。 */ + private HardCanteenUserApptDishStatisticsVo toVo() { + HardCanteenUserApptDishStatisticsVo vo = new HardCanteenUserApptDishStatisticsVo(); + vo.setHourType(hourType); + vo.setProductName(productName); + vo.setProductCount(productCount); + vo.setOrderCount((long) orderIds.size()); + vo.setTotalPrice(totalPrice); + return vo; + } + } + /** * 汇总预约明细的商品总价,历史空金额按零分计算。 * @@ -1110,336 +1079,19 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ return result; } - /** - * 将用户预约汇总转换为单行 Excel 数据,保持“一个用户 + 一个日期”导出口径。 - * - * @param row 用户预约汇总 - * @param query 原始查询条件,用于无订单时保留所选餐段 - * @param mealSegmentLabelMap 餐段编码到字典标签的映射 - * @return Excel 导出行 - */ - private HardCanteenUserApptExcelVo toExcelRow(HardCanteenUserApptVo row, - HardCanteenUserApptQueryDto query, - Map mealSegmentLabelMap) { - List orders = row.getOrders(); - List verifiedOrders = orders.stream().filter(order -> - Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.CANCEL.getType())).toList(); - HardCanteenUserApptExcelVo excel = new HardCanteenUserApptExcelVo(); - excel.setUserId(row.getUserId()); - excel.setUserName(row.getUserName()); - excel.setEmail(row.getEmail()); - excel.setPhone(row.getPhone()); - excel.setDeptName(row.getDeptName()); - excel.setPostNames(joinPostNames(row.getPostList())); - excel.setIdentityTypeNames(joinIdentityNames(row.getIdentityTypes())); - if (row.getStudentInfo() != null) { - excel.setStudentNo(row.getStudentInfo().getStudentNo()); - excel.setSchoolName(row.getStudentInfo().getSchoolName()); - excel.setClassName(row.getStudentInfo().getClassName()); - excel.setGuardianPhones(row.getStudentInfo().getGuardianPhones()); - } - if (row.getTeacherInfo() != null) { - excel.setTeacherNo(row.getTeacherInfo().getTeacherNo()); - } - excel.setApptUseDate(row.getApptUseDate()); - excel.setReservationStatus(Objects.equals(row.getReservationStatus(), RESERVATION_STATUS_RESERVED) - ? "已订餐" : "未订餐"); - excel.setHourTypes(orders.isEmpty() - ? (StrUtil.isBlank(query.getHourType()) ? "全部餐段" - : resolveMealSegmentLabel(query.getHourType(), mealSegmentLabelMap)) - : joinDistinct(orders, order -> resolveMealSegmentLabel(order.getHourType(), - mealSegmentLabelMap))); - excel.setBreakfastReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.MORNING)); - excel.setLunchReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.NOONING)); - excel.setDinnerReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.NIGHT)); - excel.setLateNightReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.LATE_NIGHT)); - excel.setMealSegmentTotalPrices(buildMealSegmentTotalPrices(orders, query, mealSegmentLabelMap)); - excel.setTotalPrice(centsToYuan(row.getTotalPrice())); - excel.setProductDescriptions(joinDistinct(orders, order -> - StrUtil.isBlank(order.getProductDesc()) ? "订餐内容未记录" : order.getProductDesc())); - excel.setMerchantNames(joinDistinct(orders, HardCanteenUserApptOrderVo::getMerchantName)); - excel.setVerificationStatus(verificationStatusText(row.getVerificationStatus())); - excel.setVerificationTimes(joinDistinct(verifiedOrders, order -> order.getUpdateTime() == null - ? "核销时间未记录" : EXCEL_DATE_TIME_FORMATTER.format(order.getUpdateTime()))); - return excel; - } - /** - * 生成用户预约导出文件末尾的金额合计行。 - * - *

合计仍使用与明细相同的导出模型,姓名列标记“合计”,餐段金额和当日金额分别写入对应统计列, - * 其余人员及预约字段保持为空。

- * - * @param summary 已累计全部导出分片的金额汇总 - * @param mealSegmentLabelMap 餐段编码到字典标签的映射 - * @return 可直接追加到 EasyExcel 工作表末尾的合计行 - */ - private HardCanteenUserApptExcelVo toExcelSummaryRow(UserReservationExportSummary summary, - Map mealSegmentLabelMap) { - HardCanteenUserApptExcelVo excel = new HardCanteenUserApptExcelVo(); - excel.setUserName("合计"); - excel.setMealSegmentTotalPrices(buildExportSummaryMealSegmentTotalPrices(summary, - mealSegmentLabelMap)); - excel.setTotalPrice(centsToYuan(summary.totalPrice)); - return excel; - } - /** - * 查询用餐时间段字典并保留字典排序;远程调用失败时由实际订单编码继续完成导出。 - * - * @return 餐段编码到展示标签的有序映射 - */ - private Map fetchMealSegmentLabelMap() { - try { - R> response = remoteDictService.getDictByType(MEAL_SEGMENT_DICT_TYPE); - if (response == null || !response.isOk() || response.getData() == null) { - log.warn("查询用餐时间段字典失败,导出将使用订单餐段编码"); - return Map.of(); - } - return response.getData().stream().filter(item -> - StrUtil.isNotBlank(item.getItemValue()) && StrUtil.isNotBlank(item.getLabel())) - .collect(Collectors.toMap(SysDictItem::getItemValue, SysDictItem::getLabel, - (left, right) -> left, LinkedHashMap::new)); - } catch (Exception exception) { - log.warn("查询用餐时间段字典异常,导出将使用订单餐段编码,原因:{}", exception.getMessage()); - return Map.of(); - } - } - /** - * 将餐段协议编码转换为导出文案,并兼容历史空编码。 - * - * @param hourType 订单或查询条件中的餐段协议编码 - * @param mealSegmentLabelMap 餐段编码到字典标签的映射 - * @return 字典标签;未知编码保留原值,空编码返回“未标记餐段” - */ - private String resolveMealSegmentLabel(String hourType, Map mealSegmentLabelMap) { - if (StrUtil.isBlank(hourType)) { - return "未标记餐段"; - } - return mealSegmentLabelMap.getOrDefault(hourType, hourType); - } - /** - * 生成一个固定餐段的预约状态。 - * - * @param orders 用户当天有效预约明细 - * @param query 原始查询条件,用于区分未订餐和未参与查询的餐段 - * @param hourType 固定餐段协议枚举 - * @return “已订N单”“未订餐”或“未查询” - */ - private String buildMealSegmentReservation(List orders, - HardCanteenUserApptQueryDto query, - HardHourTypeEnum hourType) { - if (StrUtil.isNotBlank(query.getHourType()) && !Objects.equals(query.getHourType(), hourType.getType())) { - return "未查询"; - } - long orderCount = orders.stream().filter(order -> - Objects.equals(hourType.getType(), order.getHourType())).count(); - return orderCount == 0L ? "未订餐" : "已订" + orderCount + "单"; - } - /** - * 生成各餐段商品总价摘要,未订餐段明确输出零元。 - * - * @param orders 用户当天有效预约明细 - * @param query 原始查询条件,选择餐段时只生成该餐段金额 - * @param mealSegmentLabelMap 餐段编码到字典标签的映射 - * @return 例如“早餐:0.00元;午餐:18.50元”的摘要 - */ - private String buildMealSegmentTotalPrices(List orders, - HardCanteenUserApptQueryDto query, - Map mealSegmentLabelMap) { - Map segments = resolveMealSegments(orders, query, mealSegmentLabelMap); - if (segments.isEmpty()) { - return "全部餐段:0.00元"; - } - return segments.entrySet().stream().map(entry -> { - long totalPrice = orders.stream().filter(order -> - Objects.equals(entry.getKey(), order.getHourType())) - .map(HardCanteenUserApptOrderVo::getTotalPrice).filter(Objects::nonNull) - .mapToLong(Integer::longValue).sum(); - return entry.getValue() + ":" + formatYuan(totalPrice); - }).collect(Collectors.joining(";")); - } - /** - * 格式化全部导出明细累计后的各餐段金额。 - * - * @param summary 跨分片累计的金额汇总 - * @param mealSegmentLabelMap 餐段编码到字典标签的映射 - * @return 例如“早餐:60.80元;午餐:119.00元”的餐段合计 - */ - private String buildExportSummaryMealSegmentTotalPrices(UserReservationExportSummary summary, - Map mealSegmentLabelMap) { - if (summary.mealSegmentTotalPrices.isEmpty()) { - return "全部餐段:0.00元"; - } - return summary.mealSegmentTotalPrices.entrySet().stream() - .map(entry -> resolveMealSegmentLabel(entry.getKey(), mealSegmentLabelMap) - + ":" + formatYuan(entry.getValue())) - .collect(Collectors.joining(";")); - } - /** - * 合并当前字典餐段和订单中的历史餐段编码,供预约数量及金额摘要复用。 - * - * @param orders 用户当天有效预约明细 - * @param query 原始查询条件 - * @param mealSegmentLabelMap 餐段编码到字典标签的映射 - * @return 保持字典顺序并补齐历史编码的餐段映射 - */ - private Map resolveMealSegments(List orders, - HardCanteenUserApptQueryDto query, - Map mealSegmentLabelMap) { - Map segments = new LinkedHashMap<>(); - if (StrUtil.isNotBlank(query.getHourType())) { - segments.put(query.getHourType(), - mealSegmentLabelMap.getOrDefault(query.getHourType(), query.getHourType())); - } else { - segments.putAll(mealSegmentLabelMap); - orders.stream().map(HardCanteenUserApptOrderVo::getHourType).filter(StrUtil::isNotBlank) - .forEach(hourType -> segments.putIfAbsent(hourType, hourType)); - } - return segments; - } - /** - * 提取、去空、去重并连接预约明细字段。 - * - * @param orders 预约明细列表 - * @param mapper 字段提取函数 - * @return 使用中文分号连接的字段值;没有有效值时返回空字符串 - */ - private String joinDistinct(List orders, - Function mapper) { - return orders.stream().map(mapper).filter(StrUtil::isNotBlank).distinct() - .collect(Collectors.joining(";")); - } - /** - * 将整数分转换为精确的元金额。 - * - * @param price 商品总价,单位为分;为空时按零计算 - * @return 以元为单位且固定两位小数精度的金额 - */ - private BigDecimal centsToYuan(Long price) { - return BigDecimal.valueOf(price == null ? 0L : price, 2); - } - /** - * 将整数分格式化为带元单位的两位小数文案。 - * - * @param price 商品总价,单位为分 - * @return 例如“18.50元”的金额文案 - */ - private String formatYuan(long price) { - return centsToYuan(price).toPlainString() + "元"; - } - /** - * 拼接用户岗位名称,空岗位列表输出空字符串。 - * - * @param posts 完整用户导出分页返回的岗位列表 - * @return 以中文逗号分隔的岗位名称 - */ - private String joinPostNames(List posts) { - if (posts == null || posts.isEmpty()) { - return ""; - } - return posts.stream().filter(Objects::nonNull).map(SysPost::getPostName) - .filter(StrUtil::isNotBlank).collect(Collectors.joining(",")); - } - /** - * 按用户身份协议顺序拼接身份中文名称。 - * - * @param identityTypes UPMS 返回的用户身份协议编码 - * @return 以中文逗号分隔的身份名称;未知历史编码保留原值 - */ - private String joinIdentityNames(List identityTypes) { - if (identityTypes == null || identityTypes.isEmpty()) { - return ""; - } - return identityTypes.stream().map(identityType -> { - UserIdentityTypeEnum identity = UserIdentityTypeEnum.valueOfByType(identityType); - return identity == null ? identityType : identity.getDescription(); - }).filter(StrUtil::isNotBlank).collect(Collectors.joining(",")); - } - /** - * 将核销状态协议值转换为导出文案。 - * - * @param status 核销状态协议值 - * @return 中文核销状态 - */ - private String verificationStatusText(String status) { - if (Objects.equals(status, VERIFICATION_STATUS_VERIFIED)) { - return "已核销"; - } - if (Objects.equals(status, VERIFICATION_STATUS_PARTIAL)) { - return "部分核销"; - } - if (Objects.equals(status, VERIFICATION_STATUS_UNVERIFIED)) { - return "待核销"; - } - return "无需核销"; - } - /** - * 用户预约导出跨分片金额汇总器。 - * - *

所有金额均以分为单位累计,生成最终合计行时才统一转换为元,避免分片导出过程中出现精度损失。

- */ - private static final class UserReservationExportSummary { - - /** 各餐段累计金额,键为订单餐段协议编码,值为商品总价,单位为分。 */ - private final Map mealSegmentTotalPrices = new LinkedHashMap<>(); - - /** 全部导出明细的当日金额累计值,单位为分。 */ - private long totalPrice; - - /** - * 创建跨分片汇总器,并按查询餐段或餐段字典顺序预置零金额。 - * - * @param query 本次导出的餐段筛选条件 - * @param mealSegmentLabelMap 餐段编码到字典标签的有序映射 - */ - private UserReservationExportSummary(HardCanteenUserApptQueryDto query, - Map mealSegmentLabelMap) { - if (StrUtil.isNotBlank(query.getHourType())) { - mealSegmentTotalPrices.put(query.getHourType(), 0L); - } else { - mealSegmentLabelMap.keySet().forEach(hourType -> - mealSegmentTotalPrices.put(hourType, 0L)); - } - } - - /** - * 累计一批已经通过全部查询条件筛选的用户日期明细。 - * - * @param rows 当前分片即将写入 Excel 的用户日期明细 - */ - private void addRows(List rows) { - rows.forEach(this::addRow); - } - - /** - * 累计单个用户日期明细的当日总金额和各餐段金额。 - * - * @param row 当前导出的一条用户日期明细 - */ - private void addRow(HardCanteenUserApptVo row) { - totalPrice = Math.addExact(totalPrice, row.getTotalPrice() == null ? 0L : row.getTotalPrice()); - if (row.getOrders() == null || row.getOrders().isEmpty()) { - return; - } - row.getOrders().stream().filter(Objects::nonNull) - .filter(order -> StrUtil.isNotBlank(order.getHourType())) - .forEach(order -> mealSegmentTotalPrices.merge(order.getHourType(), - order.getTotalPrice() == null ? 0L : order.getTotalPrice().longValue(), - Math::addExact)); - } - } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java index 6e6f5df8..b8656b81 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java @@ -3,29 +3,39 @@ package com.bsd.pjcloud.hard.service.impl.order; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.admin.api.entity.SysDept; +import com.bsd.pjcloud.admin.api.entity.SysDictItem; import com.bsd.pjcloud.admin.api.feign.RemoteDataScopeService; import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; +import com.bsd.pjcloud.admin.api.feign.RemoteDictService; import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenCategoryService; +import com.bsd.pjcloud.canteen.api.feign.RemoteDailyMenuService; import com.bsd.pjcloud.canteen.entity.CanteenCategoryEntity; +import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO; +import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO; import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.util.CollectionUtils; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.hard.api.constant.order.HardCanteenStatisticsLevelEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardSaleSourceEnum; import com.bsd.pjcloud.hard.api.constant.total.DateRangeTypeEnum; import com.bsd.pjcloud.hard.api.dto.order.HardCarDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppProductByCanteenDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppProductByClassifyDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppProductLikeTopDto; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo; import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByCanteenVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByClassifyVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductLikeTopVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo; import com.bsd.pjcloud.hard.mapper.order.HardCarMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.HardCanteenTotalService; @@ -38,14 +48,21 @@ import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.temporal.TemporalAdjusters; import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -63,6 +80,9 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { static private final String TAG = "食堂统计"; + /** 当前用户没有可见食堂时使用的不可命中主键,防止空集合被解释为不限制商户。 */ + private static final Long NO_VISIBLE_CANTEEN_ID = Long.MIN_VALUE; + private final HardOrderMapper hardOrderMapper; private final HardCarMapper hardCarMapper; @@ -71,6 +91,12 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { private final RemoteDataScopeService dataScopeService; private final RemoteCanteenCategoryService canteenCategoryService; + /** 每日菜谱远程服务,用于在三级导出中按业务日期展开套餐组成商品。 */ + private final RemoteDailyMenuService remoteDailyMenuService; + + /** 餐段字典远程服务,仅在生成统计导出文案时读取 daily_menu。 */ + private final RemoteDictService remoteDictService; + /** * 菜品销量按照分类查询 */ @@ -164,34 +190,139 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { @Override public List productTotalByDept(Page page, HardOrderDto dto, DataScope dataScope) { dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes()); - List list = hardCarMapper.productTotalByDept(dto, dataScope); + List list; + if (isSourceStatistics(dto)) { + normalizeSaleSources(dto); + list = hardCarMapper.productTotalByDeptStats(dto, dataScope); + } else { + list = hardCarMapper.productTotalByDept(dto, dataScope); + } + fillSaleSourceFields(list); hardCanteenProductTotalVoSetDeptData(list); return list; } /** - * 食堂菜品购买统计 + * 分页查询食堂菜品购买统计。 + * + *

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

+ * + * @param page 分页参数;新版统计的每页数量表示自然日数量 + * @param dto 统计日期、来源、食堂、餐段和部门筛选 + * @param dataScope 订单部门数据范围;为空时不追加数据权限条件 + * @return 按自然日分页且包含餐段和来源明细的统计结果 */ @Override public Page productTotal(Page page, HardOrderDto dto, DataScope dataScope) { dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes()); - Page pageList = hardCarMapper.productTotal(page, dto, null); + Page pageList; + if (isSourceStatistics(dto)) { + normalizeSaleSources(dto); + List rows = hardCarMapper.listProductTotalStats(dto, dataScope); + boolean includeSourceRows = dto.getSaleSourceCodes().size() > 1; + pageList = assembleProductStatisticsPage(page, rows, ObjectUtil.isNotEmpty(dto.getHourType()), includeSourceRows); + } else { + pageList = hardCarMapper.productTotal(page, dto, dataScope); + } if (ObjectUtil.isNotEmpty(pageList)) { for (HardCanteenProductTotalVo v : pageList.getRecords()) { - if (ObjectUtil.isEmpty(v.getCreateTime())) continue; - LocalDate localDate = v.getCreateTime().toLocalDate(); - v.setUniqueCode(localDate + "_" + v.getHourType()); + fillSaleSourceFields(v); + if (ObjectUtil.isNotEmpty(v.getCreateTime())) { + LocalDate localDate = v.getCreateTime().toLocalDate(); + v.setUniqueCode(localDate + "_" + ObjectUtil.defaultIfNull(v.getStatisticsLevel(), "LEGACY") + "_" + + ObjectUtil.defaultIfNull(v.getHourType(), "ALL")); + } } } return pageList; } + /** + * 将 Mapper 的多维统计行装配为按自然日分页的主表结果。 + * + *

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

+ * + * @param requestedPage 调用方请求的页码和每页自然日数量 + * @param rows 数据库返回的当日合计、当日来源、餐段合计和餐段来源行 + * @param hourTypeFiltered 是否已经按单一餐段筛选 + * @param includeSourceRows 是否挂载来源拆分行;仅“全部”来源视图需要展示,单来源视图禁用以避免重复行 + * @return 仅包含当前页自然日的合计行,每行附带来源对比数据 + */ + private Page assembleProductStatisticsPage(Page requestedPage, + List rows, + boolean hourTypeFiltered, + boolean includeSourceRows) { + Page result = new Page<>(requestedPage.getCurrent(), requestedPage.getSize()); + if (ObjectUtil.isEmpty(rows)) { + result.setTotal(0); + result.setRecords(List.of()); + return result; + } + + List sourceRows = rows.stream() + .filter(row -> ObjectUtil.isNotEmpty(row.getSaleSource())) + .toList(); + fillSaleSourceFields(sourceRows); + sourceRows.forEach(this::fillStatisticsLevelName); + Map> sourceRowsByScope = sourceRows.stream() + .collect(Collectors.groupingBy(this::statisticsScopeKey, LinkedHashMap::new, Collectors.toList())); + + List totalRows = rows.stream() + .filter(row -> ObjectUtil.isEmpty(row.getSaleSource())) + .filter(row -> !hourTypeFiltered + || ObjectUtil.equals(row.getStatisticsLevel(), HardCanteenStatisticsLevelEnum.HOUR_TOTAL.getCode())) + .toList(); + List statisticsDates = totalRows.stream() + .map(HardCanteenProductTotalVo::getCreateTime) + .filter(ObjectUtil::isNotEmpty) + .map(LocalDateTime::toLocalDate) + .distinct() + .toList(); + + long fromIndex = Math.min((requestedPage.getCurrent() - 1) * requestedPage.getSize(), statisticsDates.size()); + long toIndex = Math.min(fromIndex + requestedPage.getSize(), statisticsDates.size()); + Set currentDates = new HashSet<>(statisticsDates.subList((int) fromIndex, (int) toIndex)); + List currentRows = totalRows.stream() + .filter(row -> row.getCreateTime() != null && currentDates.contains(row.getCreateTime().toLocalDate())) + .peek(row -> { + HardCanteenStatisticsLevelEnum level = HardCanteenStatisticsLevelEnum.valueOfCode(row.getStatisticsLevel()); + row.setStatisticsLevelName(level == null ? null : level.getDescription()); + row.setSourceRows(includeSourceRows + ? sourceRowsByScope.getOrDefault(statisticsScopeKey(row), List.of()) + : List.of()); + }) + .toList(); + result.setTotal(statisticsDates.size()); + result.setRecords(currentRows); + return result; + } + + /** + * 构造当日或餐段统计的来源挂载键。 + * + * @param row 合计行或来源行;日期为空时返回稳定空日期片段 + * @return “日期_餐段”键,当日层级的餐段片段固定为 ALL + */ + private String statisticsScopeKey(HardCanteenProductTotalVo row) { + String statisticsDate = row == null || row.getCreateTime() == null + ? "UNKNOWN" : row.getCreateTime().toLocalDate().toString(); + return statisticsDate + "_" + ObjectUtil.defaultIfNull(row == null ? null : row.getHourType(), "ALL"); + } + /** * 菜品出售统计 */ @Override public Page proudctQuery(Page page, HardCarDto dto) { dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes()); + if (isSourceStatistics(dto)) { + normalizeSaleSources(dto); + Page result = hardCarMapper.proudctQueryStats(page, dto); + fillSaleSourceFields(result == null ? null : result.getRecords()); + return result; + } return hardCarMapper.proudctQuery(page, dto); } @@ -219,7 +350,16 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { } /** - * 食堂菜品购买部门统计 + * 查询食堂菜品部门统计。 + * + *

新版来源统计以“日期 + 食堂”的跨来源合计作为主行,并将来源拆分挂载到主行;展开后再以 + * “部门 + 餐段”的跨来源合计为主行并挂载来源拆分。所有合计均来自数据库独立聚合,避免通过 + * 来源行相加导致交易人数重复。未传统计日期范围时继续使用旧查询结构。

+ * + * @param dto 统计日期、来源、食堂、餐段和部门筛选;totalIsFindChildren 决定是否查询展开明细 + * @param user 当前登录用户,保留给既有商户范围契约 + * @param dataScope 订单所属部门数据范围;为空时不附加范围条件 + * @return 食堂部门统计主行;新版主行附带来源拆分和部门餐段明细 */ @Override public List listByDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope) { @@ -231,30 +371,56 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { List mchList = remoteMchService.scopeList(mchListDto).getData(); if (ObjectUtil.isEmpty(mchList)) return null; dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList()); - List list = hardCarMapper.listByDept(dto, dataScope); + boolean sourceStatistics = isSourceStatistics(dto); + if (sourceStatistics) normalizeSaleSources(dto); + List mapperRows = sourceStatistics + ? hardCarMapper.listByDeptStats(dto, dataScope) + : hardCarMapper.listByDept(dto, dataScope); + boolean includeSourceRows = sourceStatistics && dto.getSaleSourceCodes().size() > 1; + List list = sourceStatistics + ? assembleDepartmentMainRows(mapperRows, includeSourceRows) + : mapperRows; + fillSaleSourceFields(list); if (ObjectUtil.isNotEmpty(list) && ObjectUtil.isNotEmpty(dto.getTotalIsFindChildren()) && dto.getTotalIsFindChildren()) { // 限制最大线程数为10 int threadCount = Math.min(list.size(), 10); - ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + ExecutorService executorService = threadCount > 0 ? Executors.newFixedThreadPool(threadCount) : null; try { List> futures = list.stream() .map(v -> CompletableFuture.runAsync(() -> { // 查询时间段 HardOrderDto deptQuery = BeanUtil.toBean(dto, HardOrderDto.class); deptQuery.setMerchantId(v.getMchId()); - List deptList = hardCarMapper.listByHourType(deptQuery); + if (sourceStatistics) { + deptQuery.setCreateTimeStr(v.getCreateTime() == null ? null : v.getCreateTime().toLocalDate().toString()); + deptQuery.setOrderApptUseDateStr(null); + } + List deptMapperRows = sourceStatistics + ? hardCarMapper.listByHourTypeStats(deptQuery) + : hardCarMapper.listByHourType(deptQuery); + List deptList = sourceStatistics + ? assembleDepartmentChildRows(deptMapperRows, v.getMchId(), includeSourceRows) + : deptMapperRows; for (HardCanteenProductTotalVo j : deptList) { if (ObjectUtil.isNotEmpty(v.getMchId())) j.setMchId(v.getMchId()); + fillSaleSourceFields(j); } - v.setDeptCount(deptList.stream().map(HardCanteenProductTotalVo::getUserDeptId).distinct().toList().size()); + if (!sourceStatistics) { + v.setDeptCount(deptList.stream().map(HardCanteenProductTotalVo::getUserDeptId).distinct().toList().size()); + } v.setChildren(deptList); + if (sourceStatistics && includeSourceRows) { + for (HardCanteenProductTotalVo sourceRow : v.getSourceRows()) { + sourceRow.setChildren(departmentSourceChildren(deptList, sourceRow.getSaleSource())); + } + } }, executorService)) .toList(); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); } finally { - executorService.shutdown(); + if (executorService != null) executorService.shutdown(); } } @@ -262,9 +428,381 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { hardCanteenProductTotalVoSetMchData(list); // 添加部门信息 hardCanteenProductTotalVoSetDeptData(list); + if (sourceStatistics) inheritDepartmentRowContext(list); return list; } + /** + * 将部门统计 Mapper 主行装配为“日期 + 食堂合计 -> 来源拆分”的展示结构。 + * + * @param rows Mapper 返回的日期食堂合计行和日期食堂来源行 + * @param includeSourceRows 是否挂载来源拆分;仅多来源视图需要挂载 + * @return 日期食堂合计行列表,顺序沿用 Mapper 的日期和食堂排序 + */ + private List assembleDepartmentMainRows(List rows, + boolean includeSourceRows) { + if (ObjectUtil.isEmpty(rows)) return List.of(); + List sourceRows = rows.stream() + .filter(row -> ObjectUtil.isNotEmpty(row.getSaleSource())) + .toList(); + fillSaleSourceFields(sourceRows); + sourceRows.forEach(this::fillStatisticsLevelName); + Map> sourceRowsByScope = sourceRows.stream() + .collect(Collectors.groupingBy(this::departmentMainScopeKey, LinkedHashMap::new, Collectors.toList())); + return rows.stream() + .filter(row -> ObjectUtil.isEmpty(row.getSaleSource())) + .peek(row -> { + fillStatisticsLevelName(row); + row.setSourceRows(includeSourceRows + ? sourceRowsByScope.getOrDefault(departmentMainScopeKey(row), List.of()) + : List.of()); + }) + .toList(); + } + + /** + * 将部门展开 Mapper 行装配为“部门 + 餐段合计 -> 来源拆分”的展示结构。 + * + * @param rows Mapper 返回的部门餐段合计行和部门餐段来源行 + * @param merchantId 当前展开食堂主键,用于明细按钮继续继承食堂范围 + * @param includeSourceRows 是否挂载来源拆分;仅多来源视图需要挂载 + * @return 部门餐段合计行列表 + */ + private List assembleDepartmentChildRows(List rows, + Long merchantId, + boolean includeSourceRows) { + if (ObjectUtil.isEmpty(rows)) return List.of(); + rows.forEach(row -> row.setMchId(merchantId)); + List sourceRows = rows.stream() + .filter(row -> ObjectUtil.isNotEmpty(row.getSaleSource())) + .toList(); + fillSaleSourceFields(sourceRows); + Map> sourceRowsByScope = sourceRows.stream() + .collect(Collectors.groupingBy(this::departmentChildScopeKey, LinkedHashMap::new, Collectors.toList())); + return rows.stream() + .filter(row -> ObjectUtil.isEmpty(row.getSaleSource())) + .peek(row -> { + fillStatisticsLevelName(row); + row.setSourceRows(includeSourceRows + ? sourceRowsByScope.getOrDefault(departmentChildScopeKey(row), List.of()) + : List.of()); + }) + .toList(); + } + + /** + * 从餐段合计行中提取指定来源的部门餐段行,供主表来源行展开时复用同一批查询结果。 + * + * @param totalRows 部门餐段合计行,每行可能挂载来源拆分 + * @param saleSource 主表来源行对应的稳定来源编码 + * @return 指定来源的部门餐段行;没有匹配数据时返回空列表 + */ + private List departmentSourceChildren(List totalRows, + String saleSource) { + if (ObjectUtil.isEmpty(totalRows) || ObjectUtil.isEmpty(saleSource)) return List.of(); + return totalRows.stream() + .flatMap(row -> row.getSourceRows().stream()) + .filter(row -> ObjectUtil.equals(row.getSaleSource(), saleSource)) + .toList(); + } + + /** + * 构造部门主表合计行和来源行的挂载键。 + * + * @param row 日期食堂统计行 + * @return “日期_食堂”稳定键;空值使用占位片段避免空指针 + */ + private String departmentMainScopeKey(HardCanteenProductTotalVo row) { + String statisticsDate = row == null || row.getCreateTime() == null + ? "UNKNOWN" : row.getCreateTime().toLocalDate().toString(); + return statisticsDate + "_" + ObjectUtil.defaultIfNull(row == null ? null : row.getMchId(), -1L); + } + + /** + * 构造部门展开合计行和来源行的挂载键。 + * + * @param row 部门餐段统计行 + * @return “日期_部门_餐段”稳定键;空值使用占位片段避免空指针 + */ + private String departmentChildScopeKey(HardCanteenProductTotalVo row) { + String statisticsDate = row == null || row.getCreateTime() == null + ? "UNKNOWN" : row.getCreateTime().toLocalDate().toString(); + return statisticsDate + "_" + ObjectUtil.defaultIfNull(row == null ? null : row.getUserDeptId(), -1L) + + "_" + ObjectUtil.defaultIfNull(row == null ? null : row.getHourType(), "ALL"); + } + + /** + * 补充统计层级中文名称,来源编码为空时仍保留合计层级语义。 + * + * @param row Mapper 返回的统计行;层级为空时保持兼容旧接口 + */ + private void fillStatisticsLevelName(HardCanteenProductTotalVo row) { + if (row == null || ObjectUtil.isEmpty(row.getStatisticsLevel())) return; + HardCanteenStatisticsLevelEnum level = HardCanteenStatisticsLevelEnum.valueOfCode(row.getStatisticsLevel()); + if (level != null) row.setStatisticsLevelName(level.getDescription()); + } + + /** + * 递归将合计行的食堂、部门、日期和餐段上下文补充到来源拆分行。 + * + *

来源行来自独立聚合 SQL,只携带分组键;页面把它展开为可直接操作的普通行,因此必须继承名称和 + * 查询范围,保证购买人员与菜品明细继续使用当前食堂、部门和餐段。

+ * + * @param rows 已完成食堂和部门名称填充的合计行列表 + */ + private void inheritDepartmentRowContext(List rows) { + if (ObjectUtil.isEmpty(rows)) return; + for (HardCanteenProductTotalVo row : rows) { + inheritDepartmentRowContext(row, row.getChildren()); + inheritDepartmentRowContext(row, row.getSourceRows()); + } + } + + /** + * 将父级业务维度复制到缺少对应值的子级统计行,并继续处理其下级数据。 + * + * @param parent 提供食堂、部门、日期和餐段上下文的父级统计行 + * @param children 待补充上下文的合计行或来源拆分行 + */ + private void inheritDepartmentRowContext(HardCanteenProductTotalVo parent, + List children) { + if (parent == null || ObjectUtil.isEmpty(children)) return; + for (HardCanteenProductTotalVo child : children) { + if (child.getMchId() == null) child.setMchId(parent.getMchId()); + if (ObjectUtil.isEmpty(child.getMchName())) child.setMchName(parent.getMchName()); + if (child.getUserDeptId() == null) child.setUserDeptId(parent.getUserDeptId()); + if (ObjectUtil.isEmpty(child.getUserDeptName())) child.setUserDeptName(parent.getUserDeptName()); + if (child.getCreateTime() == null) child.setCreateTime(parent.getCreateTime()); + if (ObjectUtil.isEmpty(child.getHourType())) child.setHourType(parent.getHourType()); + inheritDepartmentRowContext(child, child.getChildren()); + inheritDepartmentRowContext(child, child.getSourceRows()); + } + } + + /** + * 导出菜品统计,直接复用来源统计 SQL,确保导出与主表的业务发生时间口径一致。 + * + * @param dto 来源统计请求 + * @param dataScope 部门数据权限 + * @return Excel 导出行 + */ + @Override + public List exportProduct(HardOrderDto dto, DataScope dataScope) { + if (!isSourceStatistics(dto)) { + throw new IllegalArgumentException("导出必须选择统计日期范围"); + } + normalizeSaleSources(dto); + List rows = hardCarMapper.listProductTotalStats(dto, dataScope).stream() + .filter(row -> ObjectUtil.equals(row.getStatisticsLevel(), HardCanteenStatisticsLevelEnum.HOUR_SOURCE.getCode())) + .toList(); + fillSaleSourceFields(rows); + Map hourTypeNames = getHourTypeNames(); + if (ObjectUtil.isEmpty(rows)) return List.of(); + return rows.stream().map(row -> { + HardCanteenProductTotalExcelVo excel = new HardCanteenProductTotalExcelVo(); + excel.setStatisticsDate(row.getCreateTime() == null ? null : row.getCreateTime().toLocalDate()); + excel.setHourTypeName(hourTypeNames.getOrDefault(row.getHourType(), row.getHourType())); + excel.setSaleSourceName(row.getSaleSourceName()); + excel.setPayUserCount(row.getPayUserCount()); + excel.setTimeCount(row.getTimeCount()); + excel.setPayCount(row.getPayCount()); + excel.setTotalPrice(centsToYuan(row.getTotalPrice())); + return excel; + }).toList(); + } + + /** + * 导出部门统计,复用部门主表及子行装配结果后扁平化为 Excel 行。 + * + * @param dto 来源统计请求 + * @param user 当前登录用户 + * @param dataScope 部门数据权限 + * @return Excel 导出行 + */ + @Override + public List exportDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope) { + if (!isSourceStatistics(dto)) { + throw new IllegalArgumentException("导出必须选择统计日期范围"); + } + dto.setTotalIsFindChildren(true); + List rows = listByDept(dto, user, dataScope); + if (ObjectUtil.isEmpty(rows)) return List.of(); + Map hourTypeNames = getHourTypeNames(); + List result = new ArrayList<>(); + for (HardCanteenProductTotalVo parent : rows) { + if (ObjectUtil.isEmpty(parent.getChildren())) continue; + for (HardCanteenProductTotalVo child : parent.getChildren()) { + List exportRows = ObjectUtil.isNotEmpty(child.getSourceRows()) + ? child.getSourceRows() : List.of(child); + for (HardCanteenProductTotalVo exportRow : exportRows) { + HardCanteenDeptTotalExcelVo excel = new HardCanteenDeptTotalExcelVo(); + excel.setStatisticsDate(parent.getCreateTime() == null ? null : parent.getCreateTime().toLocalDate()); + excel.setMerchantName(parent.getMchName()); + excel.setSaleSourceName(resolveDepartmentExportSourceName(exportRow, dto)); + excel.setUserDeptName(exportRow.getUserDeptName()); + excel.setHourTypeName(hourTypeNames.getOrDefault(exportRow.getHourType(), exportRow.getHourType())); + excel.setPayUserCount(exportRow.getPayUserCount()); + excel.setTimeCount(exportRow.getTimeCount()); + excel.setPayCount(exportRow.getPayCount()); + excel.setTotalPrice(centsToYuan(exportRow.getTotalPrice())); + result.add(excel); + } + } + } + return result; + } + + /** + * 解析部门导出的具体销售来源名称。 + * + *

多来源导出使用来源拆分行自身名称;单来源查询为避免页面重复行不会挂载来源拆分, + * 此时使用已经校验过的请求来源补全名称。

+ * + * @param row 待导出的部门餐段统计行 + * @param dto 已完成来源标准化的统计请求 + * @return 可追溯的具体来源中文名称;来源异常时返回空值 + */ + private String resolveDepartmentExportSourceName(HardCanteenProductTotalVo row, HardOrderDto dto) { + if (row != null && ObjectUtil.isNotEmpty(row.getSaleSourceName())) return row.getSaleSourceName(); + if (dto == null || ObjectUtil.isEmpty(dto.getSaleSourceCodes()) || dto.getSaleSourceCodes().size() != 1) return null; + HardSaleSourceEnum source = HardSaleSourceEnum.valueOfCode(dto.getSaleSourceCodes().get(0)); + return source == null ? null : source.getDescription(); + } + + /** + * 读取餐段字典并生成编码到名称的映射;远程数据为空时导出保留原编码。 + * + * @return 餐段编码与中文名称映射 + */ + private Map getHourTypeNames() { + try { + List items = remoteDictService.getDictByType("daily_menu").getData(); + if (ObjectUtil.isEmpty(items)) return Map.of(); + return items.stream().filter(item -> ObjectUtil.isNotEmpty(item.getItemValue()) && ObjectUtil.isNotEmpty(item.getLabel())) + .collect(Collectors.toMap(SysDictItem::getItemValue, SysDictItem::getLabel, (left, right) -> left)); + } catch (Exception exception) { + log.warn("{}:读取餐段字典失败,导出将保留餐段编码", TAG, exception); + return Map.of(); + } + } + + /** + * 将持久化使用的分转换为导出展示使用的元,空金额按零处理。 + * + * @param cents 金额,单位为分 + * @return 保留两位小数的元金额 + */ + private BigDecimal centsToYuan(Long cents) { + return BigDecimal.valueOf(ObjectUtil.defaultIfNull(cents, 0L)).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + } + + /** + * 判断是否启用来源统计查询。未传统计日期时保留旧 SQL,兼容历史页面和其他服务调用方。 + * + * @param dto 统计请求 + * @return 日期范围完整时启用新版来源统计 + */ + private boolean isSourceStatistics(HardOrderDto dto) { + return validateStatisticsDateRange(dto == null ? null : dto.getStatisticsDateTimeList()); + } + + /** + * 判断菜品明细请求是否启用来源统计查询。 + * + * @param dto 菜品明细请求 + * @return 日期范围存在且有效时返回 true + */ + private boolean isSourceStatistics(HardCarDto dto) { + return validateStatisticsDateRange(dto == null ? null : dto.getStatisticsDateTimeList()); + } + + /** + * 校验统计日期闭区间;未传时表示兼容旧查询,传入后必须包含有效的开始和结束时间。 + * + * @param range 统计日期时间范围 + * @return 未传返回 false,合法范围返回 true + */ + private boolean validateStatisticsDateRange(LocalDateTime[] range) { + if (range == null) return false; + if (range.length != 2 || range[0] == null || range[1] == null) { + throw new IllegalArgumentException("统计日期范围必须包含开始和结束时间"); + } + if (range[1].isBefore(range[0])) { + throw new IllegalArgumentException("统计结束时间不能早于开始时间"); + } + return true; + } + + /** + * 校验来源编码并补全空来源为全部来源,避免未知编码导致动态 UNION 生成空 SQL。 + * + * @param dto 统计请求 + */ + private void normalizeSaleSources(HardOrderDto dto) { + dto.setSaleSourceCodes(normalizeSaleSources(dto.getSaleSourceCodes())); + } + + /** + * 校验菜品明细来源并将空来源补全为全部来源。 + * + * @param dto 菜品明细请求 + */ + private void normalizeSaleSources(HardCarDto dto) { + dto.setSaleSourceCodes(normalizeSaleSources(dto.getSaleSourceCodes())); + } + + /** + * 统一校验来源编码并去重,保证 Mapper 只接收稳定的来源协议值。 + * + * @param sources 调用方传入的来源编码 + * @return 非空且去重后的来源编码 + */ + private List normalizeSaleSources(List sources) { + if (ObjectUtil.isEmpty(sources)) { + return HardSaleSourceEnum.getCodes(); + } + Set supported = new HashSet<>(HardSaleSourceEnum.getCodes()); + if (sources.stream().anyMatch(source -> !supported.contains(source))) { + throw new IllegalArgumentException("销售来源编码不正确"); + } + return sources.stream().distinct().toList(); + } + + /** + * 为统计主表、展开行或菜品明细批量补充来源中文名称。 + * + * @param rows Mapper 返回的来源统计行;为空时不处理 + */ + private void fillSaleSourceFields(List rows) { + if (ObjectUtil.isEmpty(rows)) return; + for (Object row : rows) { + if (row instanceof HardCanteenProductTotalVo total) fillSaleSourceFields(total); + if (row instanceof HardCarVo car) fillSaleSourceFields(car); + } + } + + /** + * 为菜品统计主表或展开行补充来源名称。 + * + * @param row 单条菜品统计行;来源为空时保留兼容旧查询的空值 + */ + private void fillSaleSourceFields(HardCanteenProductTotalVo row) { + if (row == null || ObjectUtil.isEmpty(row.getSaleSource())) return; + HardSaleSourceEnum source = HardSaleSourceEnum.valueOfCode(row.getSaleSource()); + if (source != null) row.setSaleSourceName(source.getDescription()); + } + + /** + * 为菜品汇总明细补充来源名称。 + * + * @param row 单条菜品明细;来源为空时保留兼容旧查询的空值 + */ + private void fillSaleSourceFields(HardCarVo row) { + if (row == null || ObjectUtil.isEmpty(row.getSaleSource())) return; + HardSaleSourceEnum source = HardSaleSourceEnum.valueOfCode(row.getSaleSource()); + if (source != null) row.setSaleSourceName(source.getDescription()); + } + /** * 为HardCanteenProductTotalVo增加部门信息 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java index 0c37f307..8686df17 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java @@ -173,6 +173,17 @@ public class HardCarServiceImpl extends ServiceImpl getByParentCarIds(List parentCarIds) { + return baseMapper.listByParentCarIds(parentCarIds); + } + @Override public HardParentCarEntity getParentCarById(Long id) { return hardParentCarMapper.selectById(id); @@ -325,7 +336,7 @@ public class HardCarServiceImpl extends ServiceImpl productList) { List productIds = productList.stream().map(HardCreateCarDto::getProductId).distinct().toList(); - R> response = canteenSpuService.getSpuListByIds(productIds); + R> response = canteenSpuService.getInnerSpuListByIds(productIds); if (response == null || !response.isOk() || response.getData() == null) { String message = response == null || response.getMsg() == null ? "查询每日菜谱商品失败" : response.getMsg(); throw new RuntimeException(message); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderEvidenceImageServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderEvidenceImageServiceImpl.java new file mode 100644 index 00000000..f54f3ba1 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderEvidenceImageServiceImpl.java @@ -0,0 +1,693 @@ +package com.bsd.pjcloud.hard.service.impl.order; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.lock.LockInfo; +import com.baomidou.lock.LockTemplate; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bsd.pjcloud.admin.api.feign.RemoteFileService; +import com.bsd.pjcloud.admin.api.feign.RemoteUserFaceService; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; +import com.bsd.pjcloud.common.core.util.BeanUtils; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderEvidenceRoleEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderEvidenceStatusEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderEvidenceTypeEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceMetadataDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceSubmitDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceUploadDto; +import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEvidenceImageEntity; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceImageVo; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceResolveVo; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceUploadResultVo; +import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; +import com.bsd.pjcloud.hard.mapper.order.HardOrderEvidenceImageMapper; +import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; +import com.bsd.pjcloud.hard.service.HardOrderEvidenceImageService; +import feign.Response; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.util.UriComponentsBuilder; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * 订单识别证据服务实现,证据文件和订单事务相互独立,失败只影响证据状态。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HardOrderEvidenceImageServiceImpl extends ServiceImpl + implements HardOrderEvidenceImageService { + + /** + * 证据服务诊断日志标签,日志中不输出图片、Token 或本地完整路径。 + */ + private static final String TAG = "订单识别证据"; + + /** + * 终端现场图最大接收大小,单位字节;终端目标约100KB,服务端留出编码差异余量。 + */ + private static final int LIVE_IMAGE_MAX_BYTES = 512 * 1024; + + /** + * 后台参考人脸原图最大读取大小,单位字节,防止异常文件耗尽服务内存。 + */ + private static final int REFERENCE_IMAGE_MAX_BYTES = 10 * 1024 * 1024; + + /** + * 终端现场图允许的最大边长,超过时拒绝而不在服务端二次改变证据内容。 + */ + private static final int LIVE_IMAGE_MAX_EDGE = 640; + + /** + * 单个证据位置分布式锁的最长持有时间,单位毫秒。 + */ + private static final long EVIDENCE_LOCK_EXPIRE_MILLIS = 180_000L; + + /** + * 并发上传同一证据时等待锁的最长时间,单位毫秒。 + */ + private static final long EVIDENCE_LOCK_WAIT_MILLIS = 5_000L; + + /** + * 文件中心保存订单证据时使用的稳定目录前缀。 + */ + private static final String FILE_DIRECTORY_PREFIX = "order-evidence"; + + /** + * 文件中心标识订单证据文件的稳定业务类型。 + */ + private static final String FILE_TYPE = "order-evidence"; + + /** + * 服务端创建订单缺少终端订单号时使用的证据内部引用前缀;仅写入证据快照,不回写订单主表。 + */ + private static final String SERVER_ORDER_REFERENCE_PREFIX = "SERVER_ORDER_"; + + /** + * UPMS 人脸图片受信下载接口的路径后缀;其他来源地址一律拒绝。 + */ + private static final String TRUSTED_FACE_FILE_PATH = "/sys-file/oss/file"; + + /** + * 订单主表 Mapper,用于按终端订单号和设备校验订单归属。 + */ + private final HardOrderMapper hardOrderMapper; + + /** + * 设备 Mapper,用于确认请求头 DEVICE-ID 属于当前租户的有效设备。 + */ + private final HardManagerMapper hardManagerMapper; + + /** + * UPMS 人脸远程服务,用于校验用户、样本和审核通过原图的一致性。 + */ + private final RemoteUserFaceService remoteUserFaceService; + + /** + * UPMS 文件中心远程服务,用于读取受信参考图并保存订单专属文件。 + */ + private final RemoteFileService remoteFileService; + + /** + * 分布式锁模板,用于串行化同一订单证据位置的并发上传。 + */ + private final LockTemplate lockTemplate; + + /** + * 按终端订单号或服务端订单 ID 解析订单并返回当前证据状态。 + * + * @param hardId 当前设备主键 + * @param androidOrderCode 终端本地订单号,预约核销证据允许为空 + * @param orderId 服务端订单主键,现场下单证据允许为空 + * @return 当前订单状态及已登记证据 + */ + @Override + public HardOrderEvidenceResolveVo resolve(Long hardId, String androidOrderCode, Long orderId) { + HardOrderEntity order = requireOrder(hardId, androidOrderCode, orderId, false); + return HardOrderEvidenceResolveVo.builder() + .orderId(order.getId()) + .orderStatus(order.getOrderStatus()) + .evidenceImages(listByOrderId(order.getId())) + .build(); + } + + /** + * 提交元数据时先建立占位;参考人脸继续执行受信复制,现场图等待 multipart 上传。 + */ + @Override + public HardOrderEvidenceUploadResultVo submit(Long hardId, HardOrderEvidenceSubmitDto dto) { + HardOrderEntity order = requireOrder(hardId, dto.getAndroidOrderCode(), dto.getOrderId(), true); + validateMetadata(dto, order); + HardOrderEvidenceStatusEnum requestedStatus = HardOrderEvidenceStatusEnum.valueOfCode(dto.getStatus()); + if (requestedStatus != HardOrderEvidenceStatusEnum.PENDING + && requestedStatus != HardOrderEvidenceStatusEnum.UNAVAILABLE) { + throw new RuntimeException("终端只允许提交待补传或永久缺失状态"); + } + if (requestedStatus == HardOrderEvidenceStatusEnum.UNAVAILABLE && StrUtil.isBlank(dto.getFailureReason())) { + throw new RuntimeException("永久缺失时必须说明失败原因"); + } + return withEvidenceLock(order, dto, () -> { + HardOrderEvidenceImageEntity entity = upsertMetadata(order, dto, dto.getAppVersion()); + if (HardOrderEvidenceStatusEnum.AVAILABLE.getCode().equals(entity.getStatus())) { + return result(entity, false, "证据已经上传,无需重复处理"); + } + if (HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode().equals(entity.getStatus())) { + return result(entity, false, "该证据位置已记录为永久缺失,不能重新补传"); + } + if (requestedStatus == HardOrderEvidenceStatusEnum.UNAVAILABLE) { + entity.setStatus(HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode()); + entity.setFailureReason(sanitizeFailure(dto.getFailureReason())); + updateById(entity); + return result(entity, false, "已记录证据永久缺失"); + } + if (HardOrderEvidenceRoleEnum.FACE_REFERENCE.getCode().equals(dto.getEvidenceRole())) { + return copyReferenceImage(entity, order, dto); + } + return result(entity, false, "证据元数据已登记,等待现场图片上传"); + }); + } + + /** + * 上传现场图片时严格校验哈希、格式、尺寸和唯一位置,文件中心成功后才切换 AVAILABLE。 + */ + @Override + public HardOrderEvidenceUploadResultVo upload(Long hardId, HardOrderEvidenceUploadDto dto, MultipartFile file) { + HardOrderEntity order = requireOrder(hardId, dto.getAndroidOrderCode(), dto.getOrderId(), true); + validateMetadata(dto, order); + if (HardOrderEvidenceRoleEnum.FACE_REFERENCE.getCode().equals(dto.getEvidenceRole())) { + throw new RuntimeException("后台参考人脸必须由服务端复制,终端不能直接上传"); + } + if (StrUtil.isBlank(dto.getImageHash())) { + throw new RuntimeException("现场图片哈希不能为空"); + } + ImageSnapshot image = readImage(file, LIVE_IMAGE_MAX_BYTES, true); + if (!dto.getImageHash().equalsIgnoreCase(image.hash())) { + throw new RuntimeException("现场图片内容与终端哈希不一致"); + } + validateReportedImage(dto, image); + return withEvidenceLock(order, dto, () -> { + HardOrderEvidenceImageEntity entity = upsertMetadata(order, dto, dto.getAppVersion()); + if (HardOrderEvidenceStatusEnum.AVAILABLE.getCode().equals(entity.getStatus())) { + if (!image.hash().equalsIgnoreCase(entity.getImageHash())) { + throw new RuntimeException("该证据位置已保存其他图片,禁止覆盖"); + } + return result(entity, false, "相同证据已经上传"); + } + if (HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode().equals(entity.getStatus())) { + return result(entity, false, "该证据位置已记录为永久缺失,不能重新补传"); + } + validateHashConflict(entity, image.hash()); + storeImage(entity, order, image, evidenceFileName(order, dto, image.extension())); + return result(entity, true, "现场证据上传成功"); + }); + } + + /** + * 订单提交的证据清单只创建 PENDING 占位,不执行远程文件调用,确保订单主链路快速完成。 + */ + @Override + @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW) + public void registerOrderEvidence(HardOrderEntity order, List metadataList, String appVersion) { + if (order == null || metadataList == null || metadataList.isEmpty()) return; + for (HardOrderEvidenceMetadataDto metadata : metadataList) { + validateMetadata(metadata, order); + HardOrderEvidenceImageEntity existing = baseMapper.findPosition(order.getId(), metadata.getEvidenceType(), + metadata.getEvidenceRole(), metadata.getSequenceNo()); + if (existing != null) { + validateHashConflict(existing, metadata.getImageHash()); + continue; + } + HardOrderEvidenceImageEntity entity = buildEntity(order, metadata, appVersion); + entity.setStatus(HardOrderEvidenceStatusEnum.PENDING.getCode()); + save(entity); + } + } + + /** + * 转换数据库实体并补齐稳定编码的中文名称,未知历史编码保持原值便于排障。 + */ + @Override + public List listByOrderId(Long orderId) { + if (orderId == null) return List.of(); + return baseMapper.listByOrderId(orderId).stream().map(entity -> { + HardOrderEvidenceImageVo vo = BeanUtils.toBean(entity, HardOrderEvidenceImageVo.class); + HardOrderEvidenceTypeEnum type = HardOrderEvidenceTypeEnum.valueOfCode(entity.getEvidenceType()); + HardOrderEvidenceRoleEnum role = HardOrderEvidenceRoleEnum.valueOfCode(entity.getEvidenceRole()); + HardOrderEvidenceStatusEnum status = HardOrderEvidenceStatusEnum.valueOfCode(entity.getStatus()); + vo.setEvidenceTypeName(type == null ? entity.getEvidenceType() : type.getDescription()); + vo.setEvidenceRoleName(role == null ? entity.getEvidenceRole() : role.getDescription()); + vo.setStatusName(status == null ? entity.getStatus() : status.getDescription()); + return vo; + }).toList(); + } + + /** + * 校验设备存在,并按设备和至少一种订单标识限定订单,避免跨设备探测或写入证据。 + * + *

预约订单只有在核销成功后才会把 {@code hard_id} 更新为当前设备,因此终端即使提前知道 + * 服务端订单 ID,也不能在核销前或其他设备上写入证据。

+ * + * @param hardId 当前设备主键 + * @param androidOrderCode 终端本地订单号,允许为空 + * @param orderId 服务端订单主键,允许为空 + * @param requirePaid 是否要求订单处于已支付、已退款或已核销状态 + * @return 属于当前设备的订单 + */ + private HardOrderEntity requireOrder(Long hardId, String androidOrderCode, Long orderId, boolean requirePaid) { + if (hardId == null) throw new RuntimeException("DEVICE-ID不能为空"); + if (StrUtil.isBlank(androidOrderCode) && orderId == null) { + throw new RuntimeException("终端订单号和服务端订单ID不能同时为空"); + } + HardManagerEntity hard = hardManagerMapper.selectById(hardId); + if (hard == null) throw new RuntimeException("当前租户下设备不存在"); + LambdaQueryWrapper query = new LambdaQueryWrapper() + .eq(HardOrderEntity::getHardId, hardId) + .eq(orderId != null, HardOrderEntity::getId, orderId) + .eq(StrUtil.isNotBlank(androidOrderCode), HardOrderEntity::getAndroidOrderCode, androidOrderCode); + HardOrderEntity order = hardOrderMapper.selectOne(query); + if (order == null) throw new RuntimeException("订单不存在或不属于当前设备"); + if (requirePaid && !HardOrderStatusEnum.getPayPrice().contains(order.getOrderStatus())) { + throw new RuntimeException("订单尚未确认支付成功,暂不能保存识别证据"); + } + return order; + } + + /** + * 校验类型、角色、序号、用户和人脸样本约束,阻止终端构造不一致的证据位置。 + */ + private void validateMetadata(HardOrderEvidenceMetadataDto dto, HardOrderEntity order) { + if (dto == null) throw new RuntimeException("证据元数据不能为空"); + HardOrderEvidenceTypeEnum type = HardOrderEvidenceTypeEnum.valueOfCode(dto.getEvidenceType()); + HardOrderEvidenceRoleEnum role = HardOrderEvidenceRoleEnum.valueOfCode(dto.getEvidenceRole()); + if (type == null || role == null) throw new RuntimeException("证据类型或图片角色不合法"); + if (dto.getSequenceNo() == null || dto.getSequenceNo() < 0 || dto.getSequenceNo() > 99) { + throw new RuntimeException("证据图片序号不合法"); + } + if (type == HardOrderEvidenceTypeEnum.FACE_RECOGNITION) { + if (role != HardOrderEvidenceRoleEnum.FACE_REFERENCE && role != HardOrderEvidenceRoleEnum.FACE_LIVE) { + throw new RuntimeException("人脸识别证据角色不合法"); + } + if (dto.getSequenceNo() != 0) throw new RuntimeException("人脸识别证据序号必须为0"); + if (dto.getUserId() == null || dto.getUserId() <= 0 || StrUtil.isBlank(dto.getFaceSampleId())) { + throw new RuntimeException("人脸识别证据缺少用户或样本编号"); + } + if (!Objects.equals(order.getUserId(), dto.getUserId())) { + throw new RuntimeException("识别用户与订单用户不一致"); + } + if (role == HardOrderEvidenceRoleEnum.FACE_REFERENCE && StrUtil.isBlank(dto.getReferenceImageUrl())) { + throw new RuntimeException("参考人脸证据缺少识别时图片地址"); + } + } else if (role != HardOrderEvidenceRoleEnum.MENU_LIVE) { + throw new RuntimeException("菜谱识别证据角色不合法"); + } + } + + /** + * 创建或更新 PENDING 元数据,AVAILABLE 和 UNAVAILABLE 终态只做哈希一致性检查并保持不可变。 + */ + private HardOrderEvidenceImageEntity upsertMetadata(HardOrderEntity order, HardOrderEvidenceMetadataDto dto, String appVersion) { + HardOrderEvidenceImageEntity entity = baseMapper.findPosition(order.getId(), dto.getEvidenceType(), + dto.getEvidenceRole(), dto.getSequenceNo()); + if (entity == null) { + entity = buildEntity(order, dto, appVersion); + entity.setStatus(HardOrderEvidenceStatusEnum.PENDING.getCode()); + save(entity); + return entity; + } + validateHashConflict(entity, dto.getImageHash()); + if (HardOrderEvidenceStatusEnum.AVAILABLE.getCode().equals(entity.getStatus()) + || HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode().equals(entity.getStatus())) { + return entity; + } + fillMetadata(entity, dto, appVersion); + entity.setStatus(HardOrderEvidenceStatusEnum.PENDING.getCode()); + entity.setFailureReason(null); + updateById(entity); + return entity; + } + + /** + * 构造新的证据占位实体并写入订单、设备和租户快照。 + */ + private HardOrderEvidenceImageEntity buildEntity(HardOrderEntity order, HardOrderEvidenceMetadataDto dto, String appVersion) { + HardOrderEvidenceImageEntity entity = new HardOrderEvidenceImageEntity(); + entity.setOrderId(order.getId()); + // 预约核销订单不是消费机现场创建的,通常没有 android_order_code;历史证据表该列为非空, + // 因此保存现有服务端订单号或订单主键生成的内部引用。现场订单仍保留真实终端订单号。 + entity.setAndroidOrderCode(resolveEvidenceOrderReference(order)); + entity.setHardId(order.getHardId()); + entity.setTenantId(TenantContextHolder.getTenantId()); + fillMetadata(entity, dto, appVersion); + return entity; + } + + /** + * 解析证据表必须保存的稳定订单引用,避免预约订单因没有终端订单号而无法写入证据。 + * + * @param order 已完成持久化的订单;现场订单应包含终端订单号,服务端订单至少应包含订单号或主键 + * @return 真实终端订单号、服务端订单号或基于服务端主键生成的内部引用 + */ + private String resolveEvidenceOrderReference(HardOrderEntity order) { + if (order == null) throw new RuntimeException("订单证据缺少订单信息"); + if (StrUtil.isNotBlank(order.getAndroidOrderCode())) return order.getAndroidOrderCode(); + if (StrUtil.isNotBlank(order.getOrderNo())) return order.getOrderNo(); + if (order.getId() == null) throw new RuntimeException("订单证据缺少服务端订单ID"); + return SERVER_ORDER_REFERENCE_PREFIX + order.getId(); + } + + /** + * 更新终端识别快照;文件中心字段只能由 storeImage 写入,不能由终端覆盖。 + */ + private void fillMetadata(HardOrderEvidenceImageEntity entity, HardOrderEvidenceMetadataDto dto, String appVersion) { + entity.setEvidenceType(dto.getEvidenceType()); + entity.setEvidenceRole(dto.getEvidenceRole()); + entity.setSequenceNo(dto.getSequenceNo()); + entity.setUserId(dto.getUserId()); + entity.setFaceSampleId(dto.getFaceSampleId()); + entity.setReferenceImageUrl(dto.getReferenceImageUrl()); + entity.setSimilarity(dto.getSimilarity()); + entity.setSimilarityThreshold(dto.getSimilarityThreshold()); + entity.setCapturedAt(dto.getCapturedAt()); + if (StrUtil.isNotBlank(dto.getImageHash())) entity.setImageHash(dto.getImageHash().toLowerCase()); + if (dto.getImageSize() != null) entity.setImageSize(dto.getImageSize()); + if (dto.getImageWidth() != null) entity.setImageWidth(dto.getImageWidth()); + if (dto.getImageHeight() != null) entity.setImageHeight(dto.getImageHeight()); + if (StrUtil.isNotBlank(appVersion)) entity.setAppVersion(appVersion); + } + + /** + * 通过 UPMS 权威记录校验参考图,来源已变化时标记永久缺失,远程暂时失败则保留 PENDING 重试。 + */ + private HardOrderEvidenceUploadResultVo copyReferenceImage(HardOrderEvidenceImageEntity entity, + HardOrderEntity order, + HardOrderEvidenceMetadataDto dto) { + R sourceResult = remoteUserFaceService.getEvidenceSource(dto.getUserId(), dto.getFaceSampleId()); + if (sourceResult == null || !sourceResult.isOk()) { + throw new RuntimeException(sourceResult == null || StrUtil.isBlank(sourceResult.getMsg()) + ? "查询后台参考人脸失败" : sourceResult.getMsg()); + } + SysUserFaceEvidenceSourceVo source = sourceResult.getData(); + if (source == null || StrUtil.isBlank(source.getFaceImg())) { + return markUnavailable(entity, "识别命中的后台人脸已不存在或不再可用"); + } + if (!Objects.equals(dto.getReferenceImageUrl(), source.getFaceImg())) { + return markUnavailable(entity, "后台参考人脸在识别后已发生变化"); + } + if (dto.getReferenceImageVersion() != null + && !Objects.equals(dto.getReferenceImageVersion(), source.getSourceUpdatedAt())) { + return markUnavailable(entity, "后台参考人脸版本在识别后已发生变化"); + } + String sourceFileName; + try { + sourceFileName = trustedFileName(source.getFaceImg()); + } catch (RuntimeException exception) { + return markUnavailable(entity, exception.getMessage()); + } + ImageSnapshot image; + try (Response response = remoteFileService.getFile(sourceFileName)) { + if (response == null || response.status() < 200 || response.status() >= 300 || response.body() == null) { + throw new RuntimeException("读取后台参考人脸失败"); + } + image = readImage(response.body().asInputStream(), sourceFileName, REFERENCE_IMAGE_MAX_BYTES, false); + } catch (IOException exception) { + throw new RuntimeException("读取后台参考人脸失败", exception); + } + if (StrUtil.isNotBlank(dto.getImageHash()) && !dto.getImageHash().equalsIgnoreCase(image.hash())) { + return markUnavailable(entity, "后台参考人脸内容与识别时哈希不一致"); + } + validateHashConflict(entity, image.hash()); + storeImage(entity, order, image, evidenceFileName(order, dto, image.extension())); + return result(entity, true, "后台参考人脸已复制为订单证据"); + } + + /** + * 将不可恢复的参考图变化记录为 UNAVAILABLE,保留已有元数据供 PC 追溯。 + */ + private HardOrderEvidenceUploadResultVo markUnavailable(HardOrderEvidenceImageEntity entity, String reason) { + entity.setStatus(HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode()); + entity.setFailureReason(sanitizeFailure(reason)); + updateById(entity); + return result(entity, false, reason); + } + + /** + * 写入文件中心并在成功响应后原子更新证据记录,不删除任何历史证据文件。 + */ + @SuppressWarnings("unchecked") + private void storeImage(HardOrderEvidenceImageEntity entity, HardOrderEntity order, ImageSnapshot image, String originalName) { + MultipartFile uploadFile = new MockMultipartFile("file", originalName, image.contentType(), image.bytes()); + R uploadResult = remoteFileService.innerUpload(uploadFile, FILE_DIRECTORY_PREFIX + "/" + order.getId(), null, FILE_TYPE); + if (uploadResult == null || !uploadResult.isOk() || !(uploadResult.getData() instanceof Map storage)) { + throw new RuntimeException(uploadResult == null || StrUtil.isBlank(uploadResult.getMsg()) ? "订单证据写入文件中心失败" : uploadResult.getMsg()); + } + entity.setSysFileId(numberValue(storage.get("id"))); + entity.setStorageFileName(requiredString(storage.get("fileName"), "文件中心未返回对象名")); + entity.setImageUrl(requiredString(storage.get("url"), "文件中心未返回访问地址")); + entity.setImageHash(image.hash()); + entity.setImageSize((long) image.bytes().length); + entity.setImageWidth(image.width()); + entity.setImageHeight(image.height()); + entity.setStatus(HardOrderEvidenceStatusEnum.AVAILABLE.getCode()); + entity.setFailureReason(null); + updateById(entity); + } + + /** + * 读取终端 MultipartFile 并复用统一图片内容校验。 + */ + private ImageSnapshot readImage(MultipartFile file, int maxBytes, boolean liveImage) { + if (file == null || file.isEmpty()) throw new RuntimeException("上传图片不能为空"); + try { + return readImage(new ByteArrayInputStream(file.getBytes()), file.getOriginalFilename(), maxBytes, liveImage); + } catch (IOException exception) { + throw new RuntimeException("读取上传图片失败", exception); + } + } + + /** + * 有上限地读取并解码图片,拒绝伪造后缀、空内容和超大像素尺寸。 + */ + private ImageSnapshot readImage(InputStream inputStream, String originalName, int maxBytes, boolean liveImage) { + byte[] bytes = readLimitedBytes(inputStream, maxBytes); + try { + BufferedImage bufferedImage = ImageIO.read(new ByteArrayInputStream(bytes)); + if (bufferedImage == null || bufferedImage.getWidth() <= 0 || bufferedImage.getHeight() <= 0) { + throw new RuntimeException("文件不是有效图片"); + } + String extension = detectExtension(bytes, originalName); + if (liveImage && Math.max(bufferedImage.getWidth(), bufferedImage.getHeight()) > LIVE_IMAGE_MAX_EDGE) { + throw new RuntimeException("现场图片最长边不能超过640像素"); + } + String contentType = "png".equals(extension) ? "image/png" : "image/jpeg"; + return new ImageSnapshot(bytes, sha256(bytes), bufferedImage.getWidth(), bufferedImage.getHeight(), extension, contentType); + } catch (IOException exception) { + throw new RuntimeException("图片解码失败", exception); + } + } + + /** + * 按字节签名识别 JPEG/PNG,扩展名只作为错误信息补充而不作为信任依据。 + */ + private String detectExtension(byte[] bytes, String originalName) { + if (bytes.length >= 3 && (bytes[0] & 0xff) == 0xff && (bytes[1] & 0xff) == 0xd8 && (bytes[2] & 0xff) == 0xff) { + return "jpg"; + } + if (bytes.length >= 8 && (bytes[0] & 0xff) == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4e && bytes[3] == 0x47) { + return "png"; + } + throw new RuntimeException("仅支持真实的JPEG或PNG图片:" + StrUtil.blankToDefault(originalName, "未命名文件")); + } + + /** + * 按上限读取远程或上传字节流,超过上限立即拒绝以控制内存占用。 + */ + private byte[] readLimitedBytes(InputStream inputStream, int maxBytes) { + try (InputStream source = inputStream; ByteArrayOutputStream output = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + int total = 0; + int read; + while ((read = source.read(buffer)) >= 0) { + total += read; + if (total > maxBytes) throw new RuntimeException("图片文件超过允许大小"); + output.write(buffer, 0, read); + } + if (total == 0) throw new RuntimeException("图片文件不能为空"); + return output.toByteArray(); + } catch (IOException exception) { + throw new RuntimeException("读取图片文件失败", exception); + } + } + + /** + * 校验终端上报的大小和宽高,防止元数据与实际证据内容不一致。 + */ + private void validateReportedImage(HardOrderEvidenceMetadataDto dto, ImageSnapshot image) { + if (dto.getImageSize() != null && dto.getImageSize() != image.bytes().length) { + throw new RuntimeException("现场图片大小与终端上报不一致"); + } + if (dto.getImageWidth() != null && !dto.getImageWidth().equals(image.width())) { + throw new RuntimeException("现场图片宽度与终端上报不一致"); + } + if (dto.getImageHeight() != null && !dto.getImageHeight().equals(image.height())) { + throw new RuntimeException("现场图片高度与终端上报不一致"); + } + } + + /** + * 从受信文件接口地址提取对象名,禁止服务端按终端参数访问任意 URL。 + */ + private String trustedFileName(String sourceUrl) { + try { + var components = UriComponentsBuilder.fromUriString(sourceUrl).build(); + String path = components.getPath(); + String fileName = components.getQueryParams().getFirst("fileName"); + if (StrUtil.isBlank(path) || !path.endsWith(TRUSTED_FACE_FILE_PATH) || StrUtil.isBlank(fileName) + || fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) { + throw new RuntimeException("后台参考人脸来源不受信任"); + } + return fileName; + } catch (IllegalArgumentException exception) { + throw new RuntimeException("后台参考人脸地址不合法", exception); + } + } + + /** + * 相同证据位置只接受相同哈希;未知哈希的 PENDING 占位可在首次文件上传时补齐。 + */ + private void validateHashConflict(HardOrderEvidenceImageEntity existing, String incomingHash) { + if (existing != null && StrUtil.isNotBlank(existing.getImageHash()) && StrUtil.isNotBlank(incomingHash) + && !existing.getImageHash().equalsIgnoreCase(incomingHash)) { + throw new RuntimeException("该证据位置已登记其他图片哈希,禁止覆盖"); + } + } + + /** + * 使用不含租户编号的稳定业务 Key 串行化证据位置,租户前缀由 Redis 基础设施自动处理。 + */ + private T withEvidenceLock(HardOrderEntity order, HardOrderEvidenceMetadataDto dto, LockedAction action) { + String lockKey = String.format("hard:order-evidence:%s:%s:%s:%s", order.getId(), dto.getEvidenceType(), + dto.getEvidenceRole(), dto.getSequenceNo()); + LockInfo lock = lockTemplate.lock(lockKey, EVIDENCE_LOCK_EXPIRE_MILLIS, EVIDENCE_LOCK_WAIT_MILLIS); + if (lock == null) throw new RuntimeException("该订单证据正在处理,请稍后重试"); + try { + return action.run(); + } finally { + if (!lockTemplate.releaseLock(lock)) log.warn("|{}|证据锁释放失败|orderId:{}|", TAG, order.getId()); + } + } + + /** + * 生成不包含用户姓名等敏感信息的订单证据文件名。 + */ + private String evidenceFileName(HardOrderEntity order, HardOrderEvidenceMetadataDto dto, String extension) { + return String.format("order-%s-%s-%s-%s.%s", order.getId(), dto.getEvidenceType().toLowerCase(), + dto.getEvidenceRole().toLowerCase(), dto.getSequenceNo(), extension); + } + + /** + * 将任意失败说明限制为单行500字符,避免终端路径或异常栈进入证据主记录。 + */ + private String sanitizeFailure(String source) { + String value = StrUtil.blankToDefault(source, "证据文件不可用").replace('\n', ' ').replace('\r', ' ').trim(); + return value.length() <= 500 ? value : value.substring(0, 500); + } + + /** + * 计算图片 SHA-256,使用 Java 17 十六进制编码保持终端与服务端一致。 + */ + private String sha256(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前JVM不支持SHA-256", exception); + } + } + + /** + * 将文件中心返回的数值主键安全转换为 Long。 + */ + private Long numberValue(Object value) { + if (value instanceof Number number) return number.longValue(); + if (value != null && StrUtil.isNotBlank(value.toString())) { + try { + return Long.valueOf(value.toString()); + } catch (NumberFormatException ignored) { + // 统一在下方抛出文件中心协议错误。 + } + } + throw new RuntimeException("文件中心未返回有效文件主键"); + } + + /** + * 读取文件中心必需字符串字段,空值作为协议错误处理。 + */ + private String requiredString(Object value, String message) { + if (value == null || StrUtil.isBlank(value.toString())) throw new RuntimeException(message); + return value.toString(); + } + + /** + * 构造终端同步使用的简短幂等结果。 + */ + private HardOrderEvidenceUploadResultVo result(HardOrderEvidenceImageEntity entity, boolean uploaded, String message) { + return HardOrderEvidenceUploadResultVo.builder() + .evidenceId(entity.getId()) + .orderId(entity.getOrderId()) + .status(entity.getStatus()) + .imageUrl(entity.getImageUrl()) + .uploaded(uploaded) + .message(message) + .build(); + } + + /** + * 持锁执行单个证据位置动作的函数接口,允许统一保证锁释放。 + */ + @FunctionalInterface + private interface LockedAction { + + /** + * 执行可能读写证据和文件中心的业务动作。 + * + * @return 动作结果 + */ + T run(); + } + + /** + * 已校验图片的不可变内存快照,避免上传前后再次读取可变临时文件。 + * + * @param bytes 图片原始字节 + * @param hash SHA-256 哈希 + * @param width 图片宽度,单位像素 + * @param height 图片高度,单位像素 + * @param extension 由字节签名确定的扩展名 + * @param contentType 与扩展名一致的 MIME 类型 + */ + private record ImageSnapshot(byte[] bytes, String hash, int width, int height, String extension, + String contentType) { + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java index e8b94040..a70c072a 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java @@ -43,6 +43,7 @@ import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum; +import com.bsd.pjcloud.hard.api.constant.HardProductSourceEnum; import com.bsd.pjcloud.hard.api.constant.HardThirdMiStatusEnum; import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; @@ -52,7 +53,6 @@ import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum; import com.bsd.pjcloud.hard.api.constant.xfj.HardXfjTypeEnum; import com.bsd.pjcloud.hard.api.dto.hard.HandCancelActCodeDto; import com.bsd.pjcloud.hard.api.dto.hard.HardDoneHardQrCodeScanOrderDto; -import com.bsd.pjcloud.hard.api.dto.manager.HardManagerDto; import com.bsd.pjcloud.hard.api.dto.hard.HandCancelCodeDto; import com.bsd.pjcloud.hard.api.dto.hard.HardCreateOrderDto; import com.bsd.pjcloud.hard.api.dto.order.HardCreateCarDto; @@ -75,7 +75,6 @@ import com.bsd.pjcloud.hard.api.entity.address.HardUserMapAddressEntity; import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity; import com.bsd.pjcloud.hard.api.entity.order.*; import com.bsd.pjcloud.hard.api.entity.spu.HardSpuEntity; -import com.bsd.pjcloud.hard.api.utils.HardUtil; import com.bsd.pjcloud.hard.api.vo.HardDoorFlowVo; import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo; import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; @@ -89,7 +88,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.util.MchJobTimeUtils; import com.bsd.pjcloud.hard.mapper.HardThirdMiOrderMapper; -import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; import com.bsd.pjcloud.hard.mapper.order.HardMessageOutboxMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.*; @@ -172,7 +170,7 @@ public class HardOrderServiceImpl extends ServiceImplApp 可见性查询使用不关联展示表的专用统计语句,避免总数统计重复执行最新售后单等关联逻辑。

+ * + * @param page 分页参数 + * @param dto 订单筛选和结果补充选项 + * @param dataScope 后台数据范围;App 查询传 {@code null} + * @return 已完成展示信息装配的订单分页结果 + */ @Override public Page scopePageList(Page page, HardOrderDto dto, DataScope dataScope) { + if (dto.getAppViewerUserId() != null) { + Page list = appVisibilityPageList(page, dto, dataScope); + disposeList(list.getRecords(), dto); + return list; + } Page list = baseMapper.scopePageList(page, dto, dataScope); disposeList(list.getRecords(), dto); return list; } + /** + * App 订单先分页主键、再关联当前页详情,避免售后和父购物车关联参与全部历史订单的排序与分页。 + * + * @param page 原始订单分页参数 + * @param dto 已由接口层写入当前访问者和被监护人范围的查询参数 + * @param dataScope 保留的数据范围参数;当前 App 调用传 {@code null} + * @return 记录顺序与主键分页查询完全一致的订单页 + */ + private Page appVisibilityPageList(Page page, HardOrderDto dto, DataScope dataScope) { + Page idPage = new Page<>(page.getCurrent(), page.getSize(), page.searchCount()); + idPage.setCountId("appVisibilityCount"); + idPage.setMaxLimit(page.maxLimit()); + idPage.setOptimizeCountSql(page.optimizeCountSql()); + idPage.setOptimizeJoinOfCountSql(page.optimizeJoinOfCountSql()); + idPage.setOrders(page.orders()); + idPage = baseMapper.appVisibilityPageIds(idPage, dto, dataScope); + + List orderIds = idPage.getRecords(); + List orderedRecords = List.of(); + if (ObjectUtil.isNotEmpty(orderIds)) { + Map detailMap = CollectionUtils.convertMap(baseMapper.listAppPageDetails(orderIds), HardOrderVo::getId); + orderedRecords = orderIds.stream().map(detailMap::get).filter(Objects::nonNull).toList(); + } + + Page result = new Page<>(idPage.getCurrent(), idPage.getSize(), idPage.getTotal(), idPage.searchCount()); + result.setRecords(orderedRecords); + result.setOrders(page.orders()); + result.setMaxLimit(idPage.maxLimit()); + return result; + } + /** * 分页查询未识别订单 */ @@ -368,6 +418,18 @@ public class HardOrderServiceImpl extends ServiceImpl { + try { + hardOrderEvidenceImageService.registerOrderEvidence(order, dto.getEvidenceMetadataList(), dto.getAppVersion()); + } catch (Exception exception) { + // 终端本地队列仍会按 androidOrderCode 补交,服务端占位失败不得改变订单支付结果。 + log.warn("|{}|订单证据占位登记失败|orderId:{}|error:{}|", TAG, order.getId(), exception.getMessage()); + } + }; + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + action.run(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** + * 主订单事务提交后使用独立事务登记证据占位,避免文件或证据表异常污染资金事务。 + */ + @Override + public void afterCommit() { + action.run(); + } + }); + } + /** * 创建设备二维码扫一扫订单 */ @@ -839,6 +949,7 @@ public class HardOrderServiceImpl extends ServiceImpl contexts = loadDailyMenuReserveGroupContexts(payerUserId, groups); Long merchantId = contexts.get(0).parentCar().getMerchantId(); MchManageVo merchant = validateDailyMenuReserveMerchant(merchantId); - MchAutoRefundConfigVo autoRefundConfig = loadDailyMenuAutoRefundConfig(merchantId); validateMerchantReserveDinerPermissions(payerUserId, dinerUserIds, merchant); - validateDailyMenuCartProducts(merchantId, contexts); + Map inventorySnapshot = validateDailyMenuCartProducts(merchantId, contexts, dinerUserIds.size()); validateDailyMenuSingleProductLimit(merchant, contexts); List dinerUsers = Optional.ofNullable(userService.getInnerUserById(dinerUserIds)) @@ -1462,6 +1573,9 @@ public class HardOrderServiceImpl extends ServiceImpl validateSingleReservationOnlyPerMeal(merchant, dinerUserIds, context.reservation())); List createdOrders = new ArrayList<>(); + Map reservationByOrderId = new LinkedHashMap<>(); + String inventoryBizId = null; + boolean inventoryOperationAttempted = false; try { for (DailyMenuReserveGroupContext context : contexts) { String subject = buildDailyMenuReserveOrderSubject(context.carList()); @@ -1472,20 +1586,27 @@ public class HardOrderServiceImpl extends ServiceImpl createdOrders) { + private void registerDailyMenuRollbackCompensation(List createdOrders, + List contexts, + int dinerCount, + Map inventorySnapshot, + String inventoryBizId) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { throw new RuntimeException("订餐事务状态异常,请稍后重试"); } List rollbackOrders = List.copyOf(createdOrders); + List rollbackContexts = List.copyOf(contexts); + Map rollbackInventory = Map.copyOf(inventorySnapshot); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { /** - * 本地事务明确回滚时补偿远程钱包;状态未知时保留流水,避免数据库实际已提交却误退款。 + * 本地事务明确回滚时补偿远程库存和钱包;状态未知时不主动反向操作,避免数据库实际已提交却误恢复。 * * @param status Spring 事务完成状态 */ @Override public void afterCompletion(int status) { if (status == TransactionSynchronization.STATUS_ROLLED_BACK) { - compensateReserveOrderWallets(rollbackOrders); + compensateDailyMenuReserveBatch(rollbackOrders, rollbackContexts, dinerCount, + rollbackInventory, inventoryBizId); } } }); @@ -1741,18 +1873,27 @@ public class HardOrderServiceImpl extends ServiceImpl 168)) { + return expiredEnabled ? config : null; + } + if (expiredEnabled && (config.getExpiredReserveRefundDelayHours() == null + || config.getExpiredReserveRefundDelayHours() < 1 + || config.getExpiredReserveRefundDelayHours() > 168)) { return null; } return config; @@ -1773,18 +1914,31 @@ public class HardOrderServiceImpl extends ServiceImpl= 1 + && config.getExpiredReserveRefundDelayHours() <= 168 + && reservation.usageEndDateTime() != null + && config.getId() != null) { + order.setExpiredRefundConfigId(config.getId()); + order.setExpiredRefundUsageEndTime(reservation.usageEndDateTime()); + order.setExpiredRefundDeadline(reservation.usageEndDateTime().plusHours(config.getExpiredReserveRefundDelayHours())); + order.setExpiredRefundRestoreInventory(Objects.equals(config.getExpiredReserveRefundRestoreInventory(), YesNoEnum.YES.getCode()) + ? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode()); } } @@ -1853,18 +2007,23 @@ public class HardOrderServiceImpl extends ServiceImpl contexts) { + private Map validateDailyMenuCartProducts(Long merchantId, + List contexts, + int dinerCount) { List productIds = contexts.stream() .flatMap(context -> context.carList().stream()) .map(HardCarEntity::getProductId) .distinct() .toList(); - R> result = remoteCanteenSpuService.getSpuListByIds(productIds); + R> result = remoteCanteenSpuService.getInnerSpuListByIds(productIds); if (result == null || !result.isOk() || result.getData() == null) { throw new RuntimeException(result == null ? "查询每日菜谱商品无响应" : StrUtil.blankToDefault(result.getMsg(), "查询每日菜谱商品失败")); @@ -1873,6 +2032,12 @@ public class HardOrderServiceImpl extends ServiceImpl product.getId() != null) .collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first)); + Map requiredStock = contexts.stream() + .flatMap(context -> context.carList().stream()) + .collect(Collectors.toMap(HardCarEntity::getProductId, + car -> Math.multiplyExact(car.getProductCount(), dinerCount), + Math::addExact, + LinkedHashMap::new)); for (DailyMenuReserveGroupContext context : contexts) { for (HardCarEntity car : context.carList()) { CanteenSpuEntity product = productMap.get(car.getProductId()); @@ -1883,6 +2048,10 @@ public class HardOrderServiceImpl extends ServiceImpl entry : requiredStock.entrySet()) { + CanteenSpuEntity product = productMap.get(entry.getKey()); + if (product.getStock() == null || product.getStock() < entry.getValue()) { + throw new RuntimeException(product.getName() + "库存不足"); + } + } + return requiredStock.keySet().stream().collect(Collectors.toMap(Function.identity(), + productId -> productMap.get(productId).getStock(), (first, second) -> first, LinkedHashMap::new)); } /** @@ -1927,7 +2104,7 @@ public class HardOrderServiceImpl extends ServiceImpl contexts, int dinerCount, + boolean reduce, Map inventorySnapshot, + String inventoryBizId) { + // ROLLBACK 必须原样携带 APPLY 的负增量,由食堂服务负责取反,不能在调用端提前改成正数。 + int factor = -dinerCount; + Map stockChanges = contexts.stream() + .flatMap(context -> context.carList().stream()) + .collect(Collectors.toMap(HardCarEntity::getProductId, + car -> Math.multiplyExact(car.getProductCount(), factor), + Math::addExact, + LinkedHashMap::new)); + CanteenSpuUpdateStockReqDTO stockDto = new CanteenSpuUpdateStockReqDTO(); + stockDto.setBizId(inventoryBizId); + stockDto.setOperation(reduce ? "APPLY" : "ROLLBACK"); + stockDto.setItems(stockChanges.entrySet().stream().map(entry -> { + CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item(); + item.setId(entry.getKey()); + item.setIncrCount(entry.getValue()); + item.setBeforeStock(inventorySnapshot.get(entry.getKey())); + return item; + }).toList()); + R result = remoteCanteenSpuService.updateSkuStockIdempotent(stockDto); + if (result == null || !result.isOk()) { + throw new RuntimeException(reduce ? "每日菜谱库存不足或扣减失败" : "每日菜谱库存恢复失败"); + } + } + + /** + * 使用本次批次首个订单 ID 构造每日菜谱库存业务号,避免同一购物车重试命中历史回滚状态。 + * + * @param createdOrders 已分配 ID 的本次每日菜谱订单 + * @return 本批次库存幂等业务号 + */ + private String buildDailyMenuReserveInventoryBizId(List createdOrders) { + Long firstOrderId = createdOrders.stream() + .map(HardOrderEntity::getId) + .filter(Objects::nonNull) + .findFirst() + .orElseThrow(() -> new RuntimeException("每日菜谱订单批次数据异常")); + return "HARD_DAILY_MENU_ORDER_BATCH:" + firstOrderId; + } + + /** + * 补偿每日菜谱批次已尝试的基础 SPU 库存和钱包操作;远程超时按同一业务号重复调用也不会重复恢复。 + * + * @param createdOrders 已创建并可能已产生钱包支付流水的订单 + * @param contexts 原始日期餐段购物车上下文 + * @param dinerCount 实际用餐人数 + * @param inventorySnapshot 扣库存前的基础 SPU 库存快照 + * @param inventoryBizId 已尝试扣库存时的业务号;未发起库存请求时为空 + */ + private void compensateDailyMenuReserveBatch(List createdOrders, + List contexts, + int dinerCount, + Map inventorySnapshot, + String inventoryBizId) { + if (StrUtil.isNotBlank(inventoryBizId)) { + try { + updateDailyMenuReserveInventory(contexts, dinerCount, false, inventorySnapshot, inventoryBizId); + } catch (Exception compensationException) { + log.error("|{}|每日菜谱库存补偿失败,inventoryBizId:|{}|", TAG, inventoryBizId, compensationException); + } + } + compensateReserveOrderWallets(createdOrders); } /** @@ -2588,7 +2882,10 @@ public class HardOrderServiceImpl extends ServiceImpl已支付 if (ObjectUtil.equals(dto.getStatus(), HardUpdateShopStatusDto.ChangeShopStatus.PAY_SUCCESS)) { if (ObjectUtil.notEqual(order.getOrderStatus(), HardOrderStatusEnum.CREATE.getType())) { + if (isShopAlreadyPaid(order.getOrderStatus())) { + return true; + } log.error("|更改商城订单状态|将订单状态从`CREATE(1)`修改到`PAY(5)`错误订单并不是`CREATE(1)`状态"); return false; } @@ -2624,6 +2924,18 @@ public class HardOrderServiceImpl extends ServiceImpl() + .set(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.PAY.getType()) + .set(HardOrderEntity::getVerificationTime, null) + .set(HardOrderEntity::getHardId, null) + .set(HardOrderEntity::getAndroidOrderCode, null) + .eq(HardOrderEntity::getId, order.getId()) + .eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.CANCEL.getType())); + if (updated != 1) { + throw new RuntimeException("订单状态已变化,退回核销状态失败"); + } + + if (ObjectUtil.equals(order.getSysType(), HardTypeEnum.CANTEEN.getType())) { + stopPendingMealVerifiedOutbox(order); + hardBroadcastService.rollbackDoneBroadcast(order); + } + // Wallet 是独立远程事务,放在所有 Hardware 本地写操作之后,减少本地失败后二维码已提前恢复的不一致窗口。 + R codeResult = remoteCodeUseService.rollbackReservationCode(order.getId()); + if (codeResult == null || !codeResult.isOk() || !Boolean.TRUE.equals(codeResult.getData())) { + throw new RuntimeException(codeResult == null + ? "恢复预约核销码无响应" + : StrUtil.blankToDefault(codeResult.getMsg(), "恢复预约核销码失败")); + } + return true; + } + + /** + * 校验订单是否允许从已核销恢复到待核销。退款处理中禁止回退;食堂预约还必须至少保留一份 + * 可履约商品,避免把已经全额退款的历史订单重新暴露给消费机。 + * + * @param order 已持有数据库行锁的订单快照 + */ + private void validateReservationCanRollback(HardOrderEntity order) { + if (ObjectUtil.notEqual(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())) { + throw new RuntimeException("仅线上预订订单支持退回到核销前"); + } + if (!List.of(HardTypeEnum.CANTEEN.getType(), HardTypeEnum.SUPERMARKET.getType()).contains(order.getSysType())) { + throw new RuntimeException("当前业务类型不支持退回到核销前"); + } + if (ObjectUtil.notEqual(order.getOrderStatus(), HardOrderStatusEnum.CANCEL.getType())) { + throw new RuntimeException("仅已核销订单支持退回到核销前"); + } + if (!isReservationRefundStatusFulfillable(order.getRefundStatus())) { + throw new RuntimeException("该预约订单正在退款,不能退回到核销前"); + } + if (ObjectUtil.notEqual(order.getSysType(), HardTypeEnum.CANTEEN.getType())) return; + List cars = hardCarService.getByOrderId(order.getId()); + boolean hasFulfillableProduct = ObjectUtil.isNotEmpty(cars) + && cars.stream().anyMatch(car -> resolveEffectiveProductCount(car) > 0); + if (!hasFulfillableProduct) { + throw new RuntimeException("该预约订单商品已全部退款,不能退回到核销前"); + } + } + + /** + * 停止尚未完成的核销消息投递。已经成功发送的消息属于历史事实无法撤回,继续保留成功状态; + * 再次正确核销时会重新启用未成功记录并刷新业务发生时间。 + * + * @param order 正在退回核销状态的食堂预约订单 + */ + private void stopPendingMealVerifiedOutbox(HardOrderEntity order) { + hardMessageOutboxMapper.update(null, new LambdaUpdateWrapper() + .set(HardMessageOutboxEntity::getStatus, "FAILED") + .set(HardMessageOutboxEntity::getNextRetryTime, null) + .set(HardMessageOutboxEntity::getLastError, MEAL_VERIFICATION_ROLLBACK_OUTBOX_REASON) + .eq(HardMessageOutboxEntity::getEventCode, AppMessageEventEnum.MEAL_VERIFIED.getCode()) + .eq(HardMessageOutboxEntity::getOrderId, order.getId()) + .in(HardMessageOutboxEntity::getStatus, "PENDING", "PROCESSING", "RETRY")); + } + /** * 校验食堂预约订单是否仍可履约,确保旧终端直接调用通用核销接口时不能绕过退款状态与剩余数量。 * @@ -3519,11 +3918,34 @@ public class HardOrderServiceImpl extends ServiceImpl() + .eq(HardMessageOutboxEntity::getEventCode, AppMessageEventEnum.MEAL_VERIFIED.getCode()) + .eq(HardMessageOutboxEntity::getOrderId, order.getId()) + .last("limit 1")); + if (existing != null) { + if (ObjectUtil.equals(existing.getStatus(), "SUCCESS")) return; + int updated = hardMessageOutboxMapper.update(null, new LambdaUpdateWrapper() + .set(HardMessageOutboxEntity::getSourceMchId, order.getMerchantId()) + .set(HardMessageOutboxEntity::getSourceMchName, resolveMessageMerchantName(order.getMerchantId())) + .set(HardMessageOutboxEntity::getMealName, StrUtil.blankToDefault(order.getProductDesc(), "预约用餐")) + .set(HardMessageOutboxEntity::getOccurredTime, verificationTime) + .set(HardMessageOutboxEntity::getStatus, "PENDING") + .set(HardMessageOutboxEntity::getAttemptCount, 0) + .set(HardMessageOutboxEntity::getNextRetryTime, null) + .set(HardMessageOutboxEntity::getProcessedTime, null) + .set(HardMessageOutboxEntity::getLastError, null) + .eq(HardMessageOutboxEntity::getId, existing.getId())); + if (updated != 1) { + throw new IllegalStateException("重新启用用餐核销消息Outbox失败"); + } + return; + } HardMessageOutboxEntity outbox = new HardMessageOutboxEntity(); outbox.setTenantId(order.getTenantId()); outbox.setEventCode(AppMessageEventEnum.MEAL_VERIFIED.getCode()); @@ -3532,7 +3954,7 @@ public class HardOrderServiceImpl extends ServiceImpl refundCarList, + YesNoEnum isReduce, Long afterSaleId, boolean validateOnly) { + if (ObjectUtil.equals(isReduce, YesNoEnum.NO) && afterSaleId == null) { + throw new RuntimeException("食堂菜品退款库存缺少售后单 ID"); + } + List carList = hardCarService.getByParentCarId(order.getParentCarId()); + if (ObjectUtil.isEmpty(carList)) { + throw new RuntimeException("食堂菜品库存操作失败,购物车不存在"); + } + if (ObjectUtil.isNotEmpty(refundCarList)) { + carList = filterRefundInventoryCars(carList, refundCarList); + } + if (ObjectUtil.isEmpty(carList)) { + throw new RuntimeException("食堂菜品库存操作失败,退款商品不存在"); + } + + int factor = ObjectUtil.equals(isReduce, YesNoEnum.YES) ? -1 : 1; + Map stockChanges = carList.stream().collect(Collectors.toMap( + HardCarEntity::getProductId, + car -> Math.multiplyExact(car.getProductCount(), factor), + Math::addExact, + LinkedHashMap::new)); + R> productResult = remoteCanteenSpuService + .getInnerSpuListByIds(new ArrayList<>(stockChanges.keySet())); + if (productResult == null || !productResult.isOk() || productResult.getData() == null) { + throw new RuntimeException(productResult == null ? "查询食堂菜品库存无响应" + : StrUtil.blankToDefault(productResult.getMsg(), "查询食堂菜品库存失败")); + } + Map productMap = productResult.getData().stream() + .filter(Objects::nonNull) + .filter(product -> product.getId() != null) + .collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first)); + Map inventorySnapshot = new LinkedHashMap<>(); + for (Map.Entry entry : stockChanges.entrySet()) { + CanteenSpuEntity product = productMap.get(entry.getKey()); + if (product == null || !Objects.equals(product.getMerchantId(), order.getMerchantId())) { + throw new RuntimeException("食堂菜品不存在或不属于当前商户"); + } + int productType = product.getSpuType() == null ? 1 : product.getSpuType(); + if (productType != 1 && productType != 2) { + throw new RuntimeException(product.getName() + "商品模式异常,请联系商户"); + } + if (product.getStock() == null || entry.getValue() < 0 && product.getStock() < Math.abs(entry.getValue())) { + throw new RuntimeException(product.getName() + "库存不足"); + } + inventorySnapshot.put(entry.getKey(), product.getStock()); + } + if (validateOnly) return; + + // 保留已上线的 DAILY_MENU 幂等号前缀,避免升级后对历史每日菜谱订单重复扣减。 + // 售后单 ID 隔离多次部分退款;同一售后单的支付重试必须复用同一业务号,避免重复恢复库存。 + String inventoryBizId = afterSaleId == null + ? "HARD_DAILY_MENU_ORDER:" + order.getId() + : "HARD_DAILY_MENU_AFTER_SALE:" + afterSaleId; + registerCanteenSpuInventoryRollback(stockChanges, inventorySnapshot, inventoryBizId); + applyCanteenSpuInventoryChange(stockChanges, inventorySnapshot, inventoryBizId, "APPLY"); + } + + /** + * 当前 Hardware 本地事务回滚时反向撤销已经尝试的食堂基础 SPU 库存操作, + * 覆盖远程成功但本地提交失败的窗口。 + * + * @param stockChanges 原 APPLY 使用的基础 SPU 库存增量 + * @param inventorySnapshot 操作前库存快照 + * @param inventoryBizId 本次订单或售后库存业务号 + */ + private void registerCanteenSpuInventoryRollback(Map stockChanges, + Map inventorySnapshot, + String inventoryBizId) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + throw new RuntimeException("库存事务状态异常,请稍后重试"); + } + Map rollbackChanges = Map.copyOf(stockChanges); + Map rollbackSnapshot = Map.copyOf(inventorySnapshot); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** + * 本地事务明确回滚时调用基础 SPU 幂等反向操作;重复回调由业务号状态兜底。 + * + * @param status Spring 事务完成状态 + */ + @Override + public void afterCompletion(int status) { + if (status != TransactionSynchronization.STATUS_ROLLED_BACK) { + return; + } + try { + applyCanteenSpuInventoryChange(rollbackChanges, rollbackSnapshot, inventoryBizId, "ROLLBACK"); + } catch (Exception compensationException) { + log.error("|{}|食堂基础SPU库存回滚失败,inventoryBizId:|{}|", TAG, inventoryBizId, compensationException); + } + } + }); + } + + /** + * 将基础 SPU 增量转换为食堂库存协议并校验远程业务响应,APPLY 与 ROLLBACK 始终复用原增量和快照。 + * + * @param stockChanges SPU ID 与原始库存增量 + * @param inventorySnapshot 操作前库存快照 + * @param inventoryBizId 稳定库存业务号 + * @param operation APPLY 应用或 ROLLBACK 反向撤销 + */ + private void applyCanteenSpuInventoryChange(Map stockChanges, + Map inventorySnapshot, + String inventoryBizId, + String operation) { + CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO(); + dto.setBizId(inventoryBizId); + dto.setOperation(operation); + dto.setItems(stockChanges.entrySet().stream().map(entry -> { + CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item(); + item.setId(entry.getKey()); + item.setIncrCount(entry.getValue()); + item.setBeforeStock(inventorySnapshot.get(entry.getKey())); + return item; + }).toList()); + R result = remoteCanteenSpuService.updateSkuStockIdempotent(dto); + if (result == null || !result.isOk()) { + throw new RuntimeException(result == null ? "食堂菜品库存服务无响应" + : StrUtil.blankToDefault(result.getMsg(), "食堂菜品库存操作失败")); + } + } + @Override @Transactional(rollbackFor = Exception.class) public void fastenerInventory(HardOrderEntity order, YesNoEnum isReduce) { - getSelf().fastenerInventory(order, null, isReduce); + getSelf().fastenerInventory(order, null, isReduce, null); } @Override @Transactional(rollbackFor = Exception.class) public void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce) { + getSelf().fastenerInventory(order, refundCarList, isReduce, null); + } + + /** + * 按订单商品来源执行库存变更。每日菜谱和机器收银商品使用基础 SPU 幂等接口,售后单 ID 用于隔离多次部分退款; + * 其他商品来源继续沿用原库存服务和协议,避免改变既有在线订餐、超市与商城行为。 + * + * @param order 原订单 + * @param refundCarList 本次退款商品;为空表示使用整单购物车 + * @param isReduce 是否扣减库存 + * @param afterSaleId 本次售后单 ID,非退款或历史调用可为空 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce, + Long afterSaleId) { HardTypeEnum hardTypeEnum = HardTypeEnum.valueOfByType(order.getSysType()); HardOrderTypeEnum orderTypeEnum = HardOrderTypeEnum.valueOfByType(order.getOrderType()); if (!ObjectUtil.isAllNotEmpty(hardTypeEnum, orderTypeEnum)) return; String CarErrorMsg = ObjectUtil.equals(isReduce, YesNoEnum.YES) ? "扣减库存" : "恢复库存"; int base = ObjectUtil.equals(isReduce, YesNoEnum.YES) ? -1 : 1; - // 食堂扣减库存 - if (ObjectUtil.equals(hardTypeEnum, HardTypeEnum.CANTEEN) && ObjectUtil.equals(orderTypeEnum, HardOrderTypeEnum.RESERVE)) { + // 食堂库存按服务端固化的购物车来源分流,不能只看订单类型,否则机器收银 BELOW 会漏扣每日菜谱基础 SPU 库存。 + if (ObjectUtil.equals(hardTypeEnum, HardTypeEnum.CANTEEN)) { HardParentCarEntity parentCar = hardCarService.getParentCarById(order.getParentCarId()); - // 每日菜谱页面明确忽略库存;退款或取消时也必须保持对称,不能误恢复同 ID 的在线菜品库存。 - if (isDailyMenuParentCar(parentCar)) { - log.info("|{}|每日菜谱订单忽略库存变更,orderId:|{}|,isReduce:|{}|", TAG, order.getId(), isReduce); + if (usesCanteenSpuInventory(parentCar)) { + updateCanteenSpuOrderInventory(order, refundCarList, isReduce, afterSaleId, false); return; } + // 只有预约在线菜品使用 canteen_online_spu;其他未知或历史来源保持原行为,避免跨表错扣。 + if (!ObjectUtil.equals(orderTypeEnum, HardOrderTypeEnum.RESERVE)) return; // 查询购物车 List carList = hardCarService.getByParentCarId(order.getParentCarId()); if (ObjectUtil.isEmpty(carList)) throw new RuntimeException(CarErrorMsg + "错误,购物车不存在"); @@ -4234,6 +4827,12 @@ public class HardOrderServiceImpl extends ServiceImpl list, HardOrderDto dto) { if (ObjectUtil.isEmpty(list)) return; RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); @@ -4281,19 +4880,6 @@ public class HardOrderServiceImpl extends ServiceImpl deviceFuture = CompletableFuture.runAsync(() -> { - RequestContextHolder.setRequestAttributes(requestAttributes); - List hardIds = list.stream().map(HardOrderVo::getHardId).filter(ObjectUtil::isNotEmpty).distinct().toList(); - if (ObjectUtil.isNotEmpty(hardIds)) { - HardManagerDto hardManagerDto = new HardManagerDto(); - hardManagerDto.setIds(hardIds); - List hardList = hardManagerMapper.list(hardManagerDto); - if (ObjectUtil.isNotEmpty(hardList)) { - HardUtil.convertHardList(list, CollectionUtils.convertMap(hardList, HardManagerEntity::getId)); - } - } - }); // 钱包 CompletableFuture walletFuture = CompletableFuture.runAsync(() -> { RequestContextHolder.setRequestAttributes(requestAttributes); @@ -4311,11 +4897,18 @@ public class HardOrderServiceImpl extends ServiceImpl findCarListFuture = CompletableFuture.runAsync(() -> { if (dto.getIsFindCarList()) { + List parentCarIds = list.stream().map(HardOrderVo::getParentCarId) + .filter(Objects::nonNull).distinct().toList(); Map> carListsByParentId = new LinkedHashMap<>(); + if (!parentCarIds.isEmpty()) { + List carList = hardCarService.getByParentCarIds(parentCarIds); + for (HardCarEntity car : carList) { + carListsByParentId.computeIfAbsent(car.getParentCarId(), ignored -> new ArrayList<>()) + .add(BeanUtils.toBean(car, HardCarVo.class)); + } + } for (HardOrderVo v : list) { - List carList = hardCarService.getByParentCarId(v.getParentCarId()); - List carVoList = BeanUtils.toBean(carList, HardCarVo.class); - if (v.getParentCarId() != null) carListsByParentId.put(v.getParentCarId(), carVoList); + List carVoList = carListsByParentId.getOrDefault(v.getParentCarId(), new ArrayList<>()); v.setCarList(carVoList); } hardAfterSaleCarService.fillRefundQuantityStats(carListsByParentId); @@ -4340,6 +4933,6 @@ public class HardOrderServiceImpl extends ServiceImpl + + + + + + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml index 3a11b21c..1237b604 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml @@ -57,6 +57,193 @@ + + + ( + select original_car.parent_car_id, + original_car.product_id, + original_car.product_name, + original_car.product_pic, + original_car.product_price, + original_car.create_time, + '0' as del_flag, + greatest(original_car.product_count - ifnull(refund_car.product_count, 0), 0) as product_count, + greatest(original_car.total_price - ifnull(refund_car.total_price, 0), 0) as total_price + from ( + select c.parent_car_id, + c.product_id, + max(c.product_name) as product_name, + max(c.product_pic) as product_pic, + max(c.product_price) as product_price, + min(c.create_time) as create_time, + sum(ifnull(c.product_count, 0)) as product_count, + sum(ifnull(c.total_price, 0)) as total_price + from hard_car c + 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 + and refund_car.product_id = original_car.product_id + where original_car.product_count > ifnull(refund_car.product_count, 0) + ) a + + + + + + + + + hard_car a + + + + + + ( + select DATE_FORMAT(o.pay_time, '%Y-%m-%d') as stat_date, + o.pay_time as stat_time, + o.id as order_id, + o.user_id, + o.merchant_id as mch_id, + o.user_dept_id, + o.dept_id, + o.merchant_dept_id, + o.hour_type, + a.product_id, + a.product_name, + a.product_pic, + a.product_price, + a.product_count, + a.total_price, + 'OFFLINE_PURCHASE' as sale_source + from hard_car a + inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0' + inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0' + and o.merchant_type = '1' and o.order_type = '1' + and o.hour_type is not null and o.hour_type not in ('-1') + where a.del_flag = '0' + and o.pay_time is not null + and o.order_status in ('5', '20') + union all + select DATE_FORMAT(o.verification_time, '%Y-%m-%d') as stat_date, + o.verification_time as stat_time, + o.id as order_id, + o.user_id, + o.merchant_id as mch_id, + o.user_dept_id, + o.dept_id, + o.merchant_dept_id, + o.hour_type, + a.product_id, + a.product_name, + a.product_pic, + a.product_price, + a.product_count, + a.total_price, + 'RESERVATION_VERIFIED' as sale_source + from + inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0' + inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0' + and o.merchant_type = '1' and o.order_type = '2' + and (o.appt_id is not null or o.appt_rule_id is not null) + and o.order_status = '20' + and o.verification_time is not null + and o.hour_type is not null and o.hour_type not in ('-1') + ) sale_stats + + + + + + and sale_stats.stat_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]} + + + and sale_stats.sale_source in + + #{v} + + + + and sale_stats.stat_date = #{dto.createTimeStr} + + + and sale_stats.stat_date = #{dto.orderApptUseDateStr} + + + and sale_stats.mch_id = #{dto.merchantId} + + + and sale_stats.mch_id in + + #{v} + + + + and sale_stats.hour_type = #{dto.hourType} + + + + + and sale_stats.dept_id in + + + and sale_stats.dept_id not in + + + + #{v} + + + + + + and sale_stats.user_dept_id in + + + and sale_stats.user_dept_id not in + + + + #{v} + + + + + + + + + and sale_stats.dept_id in + + #{v} + + + + and sale_stats.merchant_dept_id in + + #{v} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java new file mode 100644 index 00000000..7ab76007 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java @@ -0,0 +1,176 @@ +package com.bsd.pjcloud.hard.excel; + +import com.alibaba.excel.EasyExcel; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo; +import org.apache.poi.ss.usermodel.BorderStyle; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFCellStyle; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.apache.poi.xssf.usermodel.extensions.XSSFCellBorder.BorderSide; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 菜品预约导出合并处理器测试,覆盖 Easy Excel 流式窗口刷盘后的早期行合并。 + */ +class HardCanteenProductApptExportMergeHandlerTest { + + /** + * 构造超过默认流式窗口的两组数据,验证前后日期餐段及商户部门区域都能完成合并。 + * + * @throws Exception 临时文件或工作簿读写失败时抛出 + */ + @Test + void shouldMergeAllGroupsAfterEarlierRowsAreFlushed() throws Exception { + Path output = Files.createTempFile("canteen-product-appt-merge-test-", ".xlsx"); + try { + List rows = new ArrayList<>(); + rows.add(summaryRow(LocalDate.of(2026, 8, 23), "早餐")); + rows.add(merchantRow(LocalDate.of(2026, 8, 23), "早餐")); + addProductRows(rows, 130); + rows.add(statisticRow("早餐汇总商品")); + rows.add(summaryRow(LocalDate.of(2026, 8, 23), "午餐")); + rows.add(merchantRow(LocalDate.of(2026, 8, 23), "午餐")); + addProductRows(rows, 2); + rows.add(statisticRow("午餐汇总商品")); + rows.add(summaryRow(LocalDate.of(2026, 8, 24), "早餐")); + rows.add(merchantRow(LocalDate.of(2026, 8, 24), "早餐")); + addProductRows(rows, 5); + rows.add(statisticRow("次日早餐汇总商品")); + + EasyExcel.write(output.toFile(), HardCanteenProductApptExcelVo.class) + .registerWriteHandler(new HardCanteenProductApptExportMergeHandler()) + .sheet("菜品预约统计").doWrite(rows); + + try (InputStream input = Files.newInputStream(output); Workbook workbook = new XSSFWorkbook(input)) { + Set 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("A136:A140")); + assertTrue(mergedRegions.contains("G137:G140")); + assertTrue(mergedRegions.contains("A141:A148")); + assertTrue(mergedRegions.contains("G142:G148")); + 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)); + 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(); + XSSFCellStyle sameDateMealTopStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(135) + .getCell(0).getCellStyle(); + XSSFCellStyle firstDateBottomStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(139) + .getCell(0).getCellStyle(); + XSSFCellStyle secondDateTopStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(140) + .getCell(0).getCellStyle(); + XSSFCellStyle lastDateBottomStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(147) + .getCell(0).getCellStyle(); + assertEquals(BorderStyle.MEDIUM, firstGroupTopStyle.getBorderTop()); + assertEquals(BorderStyle.MEDIUM, firstGroupSideStyle.getBorderLeft()); + assertEquals(BorderStyle.MEDIUM, firstGroupRightStyle.getBorderRight()); + assertEquals(BorderStyle.NONE, sameDateMealTopStyle.getBorderTop()); + assertEquals(BorderStyle.MEDIUM, firstDateBottomStyle.getBorderBottom()); + assertEquals(BorderStyle.MEDIUM, secondDateTopStyle.getBorderTop()); + assertEquals(BorderStyle.MEDIUM, lastDateBottomStyle.getBorderBottom()); + assertArrayEquals(new byte[]{0x5B, (byte) 0x9B, (byte) 0xD5}, + firstGroupTopStyle.getBorderColor(BorderSide.TOP).getRGB()); + assertArrayEquals(new byte[]{(byte) 0xED, 0x7D, 0x31}, + secondDateTopStyle.getBorderColor(BorderSide.TOP).getRGB()); + } + } finally { + Files.deleteIfExists(output); + } + } + + /** + * 构造日期餐段汇总行。 + * + * @param date 预约日期 + * @param hourType 餐段名称 + * @return 仅填写汇总列的导出行 + */ + private HardCanteenProductApptExcelVo summaryRow(LocalDate date, String hourType) { + HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo(); + row.setSummaryDate(date); + row.setSummaryHourType(hourType); + row.setSummaryPayUserCount(1L); + row.setSummaryTimeCount(1L); + row.setSummaryPayCount(1L); + row.setSummaryTotalPrice(BigDecimal.ONE); + return row; + } + + /** + * 构造商户部门统计行。 + * + * @param date 预约日期 + * @param hourType 餐段名称 + * @return 仅填写商户部门列的导出行 + */ + private HardCanteenProductApptExcelVo merchantRow(LocalDate date, String hourType) { + HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo(); + row.setMerchantName("测试食堂"); + row.setDepartmentName("测试部门"); + row.setMerchantDate(date); + row.setMerchantHourType(hourType); + row.setMerchantPayUserCount(1L); + row.setMerchantTimeCount(1L); + row.setMerchantPayCount(1L); + row.setMerchantTotalPrice(BigDecimal.ONE); + return row; + } + + /** + * 追加指定数量的商品明细行,用于触发流式工作簿刷出早期数据行。 + * + * @param rows 待追加的导出行集合 + * @param count 商品明细行数量 + */ + private void addProductRows(List rows, int count) { + for (int index = 0; index < count; index++) { + HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo(); + row.setProductName("测试商品" + index); + row.setProductCount(1L); + row.setProductTotalPrice(BigDecimal.ONE); + rows.add(row); + } + } + + /** + * 构造商户部门块末尾的独立商品统计行,用于验证纵向合并范围包含统计区域。 + * + * @param productName 商品统计名称 + * @return 仅填写商品统计列的导出行 + */ + private HardCanteenProductApptExcelVo statisticRow(String productName) { + HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo(); + row.setStatisticProductName(productName); + row.setStatisticProductCount(1L); + row.setStatisticProductTotalPrice(BigDecimal.ONE); + return row; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java index cfa57c56..b590483d 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java @@ -27,6 +27,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -57,6 +58,60 @@ class HardApptReservationServiceImplTest { appt, APPT_DATE, LocalDateTime.of(2026, 7, 21, 10, 0, 0, 1)).available()); } + /** 动态预约直接快照已校验餐段的实际结束时间。 */ + @Test + void dynamicReservationUsesValidatedSegmentEndTime() { + HardApptEntity dynamic = timeWindowAppt(); + dynamic.setId(1L); + dynamic.setMchId(10L); + dynamic.setHourType("1"); + dynamic.setStatus(YesNoEnum.YES.getCode()); + + var validation = reservationService(dynamic, 1, List.of()) + .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()); + } + + /** 周期预约按有效 hard_appt 餐段解析结束时间,并支持结束时间落到用餐日次日。 */ + @Test + void weeklyReservationResolvesCrossDaySegmentEndTime() { + 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"); + + var validation = reservationService(weekly, 0, List.of(rule)) + .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()); + } + + /** 周期餐段无法唯一匹配时不生成结束时间,避免按错误规则自动退款。 */ + @Test + void weeklyReservationOmitsUsageEndTimeWhenSegmentIsAmbiguous() { + 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"); + + var validation = reservationService(weekly, 0, List.of(rule), List.of(weekly, duplicate)) + .validateReservation(10L, null, 100L, "1", LocalDate.of(2026, 7, 28), null, + LocalDateTime.of(2026, 7, 24, 18, 0)); + + assertNull(validation.usageEndDateTime()); + } + @Test void configurableCycleIncludesBothBoundaryInstants() { HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0), @@ -347,12 +402,28 @@ class HardApptReservationServiceImplTest { private HardApptReservationServiceImpl reservationService(HardApptEntity appt, long mappingCount, List rules) { + return reservationService(appt, mappingCount, rules, List.of(appt)); + } + + /** + * 构造可控制实际餐段查询结果的预约服务。 + * + * @param appt 动态校验按主键读取的餐段,同时决定商户预约模式 + * @param mappingCount 动态日期与餐段映射数量 + * @param rules 当前商户有效周期规则 + * @param enabledAppts 周期模式按商户和餐段查询到的有效 hard_appt 列表 + * @return 注入测试替身的预约校验服务 + */ + private HardApptReservationServiceImpl reservationService(HardApptEntity appt, long mappingCount, + List rules, + List enabledAppts) { HardApptService apptService = mock(HardApptService.class); HardApptTimeService timeService = mock(HardApptTimeService.class); HardApptCycleRuleService cycleRuleService = mock(HardApptCycleRuleService.class); HardApptMerchantModeService merchantModeService = merchantModeService( HardApptReserveModeEnum.valueOfByType(appt.getReserveMode())); when(apptService.getById(appt.getId())).thenReturn(appt); + when(apptService.list(org.mockito.ArgumentMatchers.>any())).thenReturn(enabledAppts); when(timeService.count(any())).thenReturn(mappingCount); when(cycleRuleService.listEnabledConfigurations(appt.getMchId())).thenReturn(rules); return new HardApptReservationServiceImpl(apptService, timeService, cycleRuleService, merchantModeService); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java index 5a9aa20c..c3b5c2c4 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java @@ -5,12 +5,14 @@ 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.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.mapper.order.HardAfterSaleMapper; import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundAttemptLogMapper; import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundTaskMapper; +import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.HardAfterSaleService; import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity; import com.bsd.pjcloud.wallet.api.feign.RemoteMchAutoRefundConfigService; @@ -30,8 +32,13 @@ import java.time.LocalDateTime; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +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.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -68,6 +75,9 @@ class HardAutoRefundTaskServiceImplTest { /** 自动退款任务 Mapper Mock。 */ @Mock private HardAutoRefundTaskMapper taskMapper; + /** 订单 Mapper Mock,用于候选订单和到期退款流程的订单锁查询。 */ + @Mock + private HardOrderMapper orderMapper; /** 被测自动退款任务服务。 */ private HardAutoRefundTaskServiceImpl service; @@ -77,11 +87,11 @@ class HardAutoRefundTaskServiceImplTest { */ @BeforeEach void setUp() { - service = new HardAutoRefundTaskServiceImpl(attemptLogMapper, afterSaleMapper, afterSaleServiceProvider, - configService, walletRechargeService, mchService, userService, lockTemplate); + service = new HardAutoRefundTaskServiceImpl(attemptLogMapper, afterSaleMapper, orderMapper, + afterSaleServiceProvider, configService, walletRechargeService, mchService, userService, lockTemplate); ReflectionTestUtils.setField(service, "baseMapper", taskMapper); - when(attemptLogMapper.insert(any(HardAutoRefundAttemptLogEntity.class))).thenReturn(1); - when(taskMapper.updateById(any(HardAutoRefundTaskEntity.class))).thenReturn(1); + lenient().when(attemptLogMapper.insert(any(HardAutoRefundAttemptLogEntity.class))).thenReturn(1); + lenient().when(taskMapper.updateById(any(HardAutoRefundTaskEntity.class))).thenReturn(1); } /** @@ -165,6 +175,32 @@ class HardAutoRefundTaskServiceImplTest { assertEquals(HardAutoRefundTaskStatusEnum.SUCCESS.getStatus(), task.getStatus()); } + /** 管理端逐单退款只有在任务最终成功时才允许接口返回成功。 */ + @Test + void expiredManualRefundShouldReturnOnlyAfterTaskSuccess() { + HardAutoRefundTaskEntity pending = expiredTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus()); + HardAutoRefundTaskEntity completed = expiredTask(HardAutoRefundTaskStatusEnum.SUCCESS.getStatus()); + HardAutoRefundTaskServiceImpl spyService = spy(service); + when(taskMapper.selectById(1L)).thenReturn(pending, completed); + doNothing().when(spyService).execute(eq(1L), eq(HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL), eq(99L)); + + spyService.manualExpiredRefund(1L, 99L); + + verify(spyService).execute(1L, HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL, 99L); + } + + /** 锁竞争或资金失败未把任务推进为成功时,逐单退款接口必须返回失败而不是误报成功。 */ + @Test + void expiredManualRefundShouldFailWhenTaskDidNotComplete() { + HardAutoRefundTaskEntity pending = expiredTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus()); + HardAutoRefundTaskEntity failed = expiredTask(HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus()); + HardAutoRefundTaskServiceImpl spyService = spy(service); + when(taskMapper.selectById(1L)).thenReturn(pending, failed); + doNothing().when(spyService).execute(eq(1L), eq(HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL), eq(99L)); + + assertThrows(RuntimeException.class, () -> spyService.manualExpiredRefund(1L, 99L)); + } + /** * 构造固定订单和售后单的自动退款任务。 * @@ -177,4 +213,17 @@ class HardAutoRefundTaskServiceImplTest { .configId(5L).ruleType("PAY_TIME_LIMIT").status(HardAutoRefundTaskStatusEnum.PENDING.getStatus()) .autoAttemptCount(autoAttempts).totalAttemptCount(totalAttempts).build(); } + + /** + * 构造已到期的每日菜谱过期退款任务。 + * + * @param status 当前任务状态 + * @return 可供管理端逐单退款状态复查使用的任务 + */ + private HardAutoRefundTaskEntity expiredTask(String status) { + return HardAutoRefundTaskEntity.builder().id(1L).orderId(3L).mchId(4L).configId(5L) + .taskType(HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) + .triggerTime(LocalDateTime.now().minusMinutes(1)).status(status) + .autoAttemptCount(0).totalAttemptCount(0).build(); + } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java new file mode 100644 index 00000000..548d8f5b --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java @@ -0,0 +1,169 @@ +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 org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.util.List; + +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); + + 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 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()); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImplTest.java new file mode 100644 index 00000000..6a5e0932 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImplTest.java @@ -0,0 +1,246 @@ +package com.bsd.pjcloud.hard.service.impl.order; + +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.canteen.api.feign.RemoteCanteenCategoryService; +import com.bsd.pjcloud.common.core.util.R; +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.HardCanteenProductTotalVo; +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 java.time.LocalDateTime; +import java.util.List; + +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.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 食堂销售来源统计服务测试,验证多维层级装配、来源筛选和协议校验。 + */ +class HardCanteenTotalServiceImplTest { + + /** 菜品统计 Mapper,用于隔离 SQL 执行并检查服务传入的来源条件。 */ + private final HardCarMapper hardCarMapper = mock(HardCarMapper.class); + + /** 部门名称远程服务,用于隔离部门统计的名称补全过程。 */ + private final RemoteDeptService remoteDeptService = mock(RemoteDeptService.class); + + /** 食堂范围远程服务,用于部门统计返回可查询的食堂集合。 */ + private final RemoteMchService remoteMchService = mock(RemoteMchService.class); + + /** 被测统计服务。 */ + private HardCanteenTotalServiceImpl service; + + /** + * 使用 Mock 远程依赖构造统计服务,当前测试只执行菜品统计路由和结果装配。 + */ + @BeforeEach + void setUp() { + service = new HardCanteenTotalServiceImpl( + mock(HardOrderMapper.class), + hardCarMapper, + remoteDeptService, + remoteMchService, + mock(RemoteDataScopeService.class), + mock(RemoteCanteenCategoryService.class), + mock(RemoteDictService.class)); + } + + /** + * 验证传入统计日期时启用多维 SQL,并装配当日、餐段及各自来源明细。 + */ + @Test + void productTotalBuildsDateAndHourStatisticsWithSourceRows() { + HardOrderDto dto = statisticsDto(); + when(hardCarMapper.listProductTotalStats(any(HardOrderDto.class), isNull())).thenReturn(List.of( + statisticsRow(HardCanteenStatisticsLevelEnum.DATE_TOTAL, null, null), + statisticsRow(HardCanteenStatisticsLevelEnum.DATE_SOURCE, HardSaleSourceEnum.OFFLINE_PURCHASE, null), + statisticsRow(HardCanteenStatisticsLevelEnum.DATE_SOURCE, HardSaleSourceEnum.RESERVATION_VERIFIED, null), + statisticsRow(HardCanteenStatisticsLevelEnum.HOUR_TOTAL, null, "1"), + statisticsRow(HardCanteenStatisticsLevelEnum.HOUR_SOURCE, HardSaleSourceEnum.OFFLINE_PURCHASE, "1"), + statisticsRow(HardCanteenStatisticsLevelEnum.HOUR_SOURCE, HardSaleSourceEnum.RESERVATION_VERIFIED, "1"))); + + Page result = service.productTotal(new Page<>(1, 10), dto, null); + + assertEquals(HardSaleSourceEnum.getCodes(), dto.getSaleSourceCodes()); + assertEquals(2, result.getRecords().size()); + assertEquals(1, result.getTotal()); + assertEquals("当日合计", result.getRecords().get(0).getStatisticsLevelName()); + assertEquals("餐段合计", result.getRecords().get(1).getStatisticsLevelName()); + assertEquals(List.of("线下购买", "预约核销"), result.getRecords().get(0).getSourceRows().stream() + .map(HardCanteenProductTotalVo::getSaleSourceName).toList()); + assertEquals("2026-08-24_DATE_TOTAL_ALL", result.getRecords().get(0).getUniqueCode()); + assertEquals("2026-08-24_HOUR_TOTAL_1", result.getRecords().get(1).getUniqueCode()); + verify(hardCarMapper, never()).productTotal(any(Page.class), any(HardOrderDto.class), isNull()); + } + + /** + * 验证单来源标签页只把对应来源编码传给来源统计 Mapper。 + */ + @Test + void productTotalPreservesSingleSourceFilter() { + HardOrderDto dto = statisticsDto(); + dto.setSaleSourceCodes(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode())); + when(hardCarMapper.listProductTotalStats(any(HardOrderDto.class), isNull())).thenReturn(List.of( + statisticsRow(HardCanteenStatisticsLevelEnum.DATE_TOTAL, null, null), + statisticsRow(HardCanteenStatisticsLevelEnum.DATE_SOURCE, HardSaleSourceEnum.RESERVATION_VERIFIED, null))); + + Page result = service.productTotal(new Page<>(1, 10), dto, null); + + assertEquals(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()), dto.getSaleSourceCodes()); + assertEquals(1, result.getRecords().size()); + assertTrue(result.getRecords().get(0).getSourceRows().isEmpty()); + verify(hardCarMapper).listProductTotalStats(any(HardOrderDto.class), isNull()); + } + + /** + * 验证未知来源在进入 Mapper 前即被拒绝,避免被当作“全部”静默查询。 + */ + @Test + void productTotalRejectsUnknownSource() { + HardOrderDto dto = statisticsDto(); + dto.setSaleSourceCodes(List.of("UNKNOWN_SOURCE")); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> service.productTotal(new Page<>(1, 10), dto, null)); + + assertEquals("销售来源编码不正确", exception.getMessage()); + verify(hardCarMapper, never()).listProductTotalStats(any(HardOrderDto.class), isNull()); + } + + /** + * 验证部门统计将日期食堂合计和部门餐段合计作为主层级,并分别挂载两个来源的下钻行。 + */ + @Test + void departmentTotalBuildsMerchantAndMealStatisticsWithSourceRows() { + HardOrderDto dto = statisticsDto(); + dto.setTotalIsFindChildren(true); + mockCanteenScope(); + when(hardCarMapper.listByDeptStats(any(HardOrderDto.class), isNull())).thenReturn(List.of( + departmentRow(HardCanteenStatisticsLevelEnum.DATE_TOTAL, null, null, null), + departmentRow(HardCanteenStatisticsLevelEnum.DATE_SOURCE, HardSaleSourceEnum.OFFLINE_PURCHASE, null, null), + departmentRow(HardCanteenStatisticsLevelEnum.DATE_SOURCE, HardSaleSourceEnum.RESERVATION_VERIFIED, null, null))); + when(hardCarMapper.listByHourTypeStats(any(HardOrderDto.class))).thenReturn(List.of( + departmentRow(HardCanteenStatisticsLevelEnum.HOUR_TOTAL, null, "1", 20L), + departmentRow(HardCanteenStatisticsLevelEnum.HOUR_SOURCE, HardSaleSourceEnum.OFFLINE_PURCHASE, "1", 20L), + departmentRow(HardCanteenStatisticsLevelEnum.HOUR_SOURCE, HardSaleSourceEnum.RESERVATION_VERIFIED, "1", 20L))); + + List result = service.listByDept(dto, null, null); + + assertEquals(1, result.size()); + assertEquals("当日合计", result.get(0).getStatisticsLevelName()); + assertEquals(List.of("线下购买", "预约核销"), result.get(0).getSourceRows().stream() + .map(HardCanteenProductTotalVo::getSaleSourceName).toList()); + assertEquals(1, result.get(0).getChildren().size()); + assertEquals("餐段合计", result.get(0).getChildren().get(0).getStatisticsLevelName()); + assertEquals(2, result.get(0).getChildren().get(0).getSourceRows().size()); + 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()); + } + + /** + * 验证单来源部门标签页只返回合计行,不再附加与合计值相同的来源行。 + */ + @Test + void departmentTotalSingleSourceHidesDuplicateSourceRows() { + HardOrderDto dto = statisticsDto(); + dto.setSaleSourceCodes(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode())); + dto.setTotalIsFindChildren(true); + mockCanteenScope(); + when(hardCarMapper.listByDeptStats(any(HardOrderDto.class), isNull())).thenReturn(List.of( + departmentRow(HardCanteenStatisticsLevelEnum.DATE_TOTAL, null, null, null), + departmentRow(HardCanteenStatisticsLevelEnum.DATE_SOURCE, HardSaleSourceEnum.RESERVATION_VERIFIED, null, null))); + when(hardCarMapper.listByHourTypeStats(any(HardOrderDto.class))).thenReturn(List.of( + departmentRow(HardCanteenStatisticsLevelEnum.HOUR_TOTAL, null, "1", 20L), + departmentRow(HardCanteenStatisticsLevelEnum.HOUR_SOURCE, HardSaleSourceEnum.RESERVATION_VERIFIED, "1", 20L))); + + List result = service.listByDept(dto, null, null); + + assertEquals(1, result.size()); + assertTrue(result.get(0).getSourceRows().isEmpty()); + assertEquals(1, result.get(0).getChildren().size()); + assertTrue(result.get(0).getChildren().get(0).getSourceRows().isEmpty()); + assertEquals(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()), dto.getSaleSourceCodes()); + } + + /** + * 构造覆盖 2026-08-24 完整自然日的来源统计请求。 + * + * @return 未指定来源、由服务补全为全部来源的查询参数 + */ + private HardOrderDto statisticsDto() { + HardOrderDto dto = new HardOrderDto(); + dto.setStatisticsDateTimeList(new LocalDateTime[]{ + LocalDateTime.of(2026, 8, 24, 0, 0, 0), + LocalDateTime.of(2026, 8, 24, 23, 59, 59) + }); + return dto; + } + + /** + * 构造单日指定层级的统计行。 + * + * @param level 统计展示层级 + * @param source 销售来源枚举;合计行传空 + * @param hourType 餐段字典编码;当日层级传空 + * @return 尚未装配来源名称、层级名称和唯一键的 Mapper 结果 + */ + private HardCanteenProductTotalVo statisticsRow(HardCanteenStatisticsLevelEnum level, + HardSaleSourceEnum source, + String hourType) { + HardCanteenProductTotalVo row = new HardCanteenProductTotalVo(); + row.setCreateTime(LocalDateTime.of(2026, 8, 24, 0, 0)); + row.setHourType(hourType); + row.setSaleSource(source == null ? null : source.getCode()); + row.setStatisticsLevel(level.getCode()); + return row; + } + + /** + * 模拟当前用户可以查看编号为 10 的食堂,并提供食堂名称填充结果。 + */ + private void mockCanteenScope() { + MchManageVo merchant = new MchManageVo(); + merchant.setId(10L); + merchant.setName("测试食堂"); + when(remoteMchService.scopeList(any())).thenReturn(R.ok(List.of(merchant))); + when(remoteMchService.getByIds(List.of(10L))).thenReturn(R.ok(List.of(merchant))); + when(remoteDeptService.getDeptByDeptIds(List.of(20L))).thenReturn(R.ok(java.util.Map.of())); + } + + /** + * 构造部门统计指定展示层级的 Mapper 行。 + * + * @param level 统计展示层级 + * @param source 销售来源枚举;合计行传空 + * @param hourType 餐段字典编码;食堂主行传空 + * @param userDeptId 用户部门主键;食堂主行传空 + * @return 尚未装配层级名称和来源层级的 Mapper 结果 + */ + private HardCanteenProductTotalVo departmentRow(HardCanteenStatisticsLevelEnum level, + HardSaleSourceEnum source, + String hourType, + Long userDeptId) { + HardCanteenProductTotalVo row = statisticsRow(level, source, hourType); + row.setMchId(10L); + row.setUserDeptId(userDeptId); + return row; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplInventoryTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplInventoryTest.java new file mode 100644 index 00000000..1aa7d24c --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplInventoryTest.java @@ -0,0 +1,206 @@ +package com.bsd.pjcloud.hard.service.impl.order; + +import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenSpuService; +import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO; +import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.hard.api.constant.HardProductSourceEnum; +import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; +import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardParentCarEntity; +import com.bsd.pjcloud.hard.service.HardCarService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 机器收银食堂订单库存分流测试,确保 BELOW 订单使用 {@code canteen_spu} 原子库存接口。 + */ +class HardOrderServiceImplInventoryTest { + + /** 订单购物车服务,提供服务端固化的商品来源和下单商品快照。 */ + private final HardCarService hardCarService = mock(HardCarService.class); + + /** 食堂基础菜品远程服务,负责查询权威库存并执行幂等扣减。 */ + private final RemoteCanteenSpuService remoteCanteenSpuService = mock(RemoteCanteenSpuService.class); + + /** 被测订单服务,仅执行真实库存方法,其他依赖不参与本组测试。 */ + private final HardOrderServiceImpl service = mock(HardOrderServiceImpl.class, CALLS_REAL_METHODS); + + /** + * 注入库存分支依赖,并建立事务同步上下文供跨服务回滚补偿登记。 + */ + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "hardCarService", hardCarService); + ReflectionTestUtils.setField(service, "remoteCanteenSpuService", remoteCanteenSpuService); + TransactionSynchronizationManager.initSynchronization(); + } + + /** + * 清理线程绑定的事务同步器,避免影响同线程执行的后续测试。 + */ + @AfterEach + void tearDown() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + /** + * 验证机器收银购买数量超过权威库存时在远程扣减前明确返回商品库存不足。 + */ + @Test + void cashierOrderRejectsInsufficientCanteenSpuStock() { + prepareCart(2); + CanteenSpuEntity product = productWithStock(1); + when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(11L))).thenReturn(R.ok(List.of(product))); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> service.fastenerInventory(cashierOrder(), null, YesNoEnum.YES, null)); + + assertEquals("测试菜品库存不足", exception.getMessage()); + verify(remoteCanteenSpuService, never()).updateSkuStockIdempotent(any()); + } + + /** + * 验证机器收银库存充足时按订单数量生成基础 SPU 的 APPLY 负增量。 + */ + @Test + void cashierOrderDeductsCanteenSpuStock() { + prepareCart(2); + CanteenSpuEntity product = productWithStock(5); + when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(11L))).thenReturn(R.ok(List.of(product))); + when(remoteCanteenSpuService.updateSkuStockIdempotent(any())).thenReturn(R.ok()); + + assertDoesNotThrow(() -> service.fastenerInventory(cashierOrder(), null, YesNoEnum.YES, null)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class); + verify(remoteCanteenSpuService).updateSkuStockIdempotent(captor.capture()); + CanteenSpuUpdateStockReqDTO request = captor.getValue(); + assertEquals("APPLY", request.getOperation()); + assertEquals("HARD_DAILY_MENU_ORDER:100", request.getBizId()); + assertEquals(-2, request.getItems().get(0).getIncrCount()); + assertEquals(5, request.getItems().get(0).getBeforeStock()); + } + + /** + * 验证聚合码创建前的库存预检只读权威库存,库存充足时不提前扣减。 + */ + @Test + void aggregatePreflightChecksStockWithoutDeduction() { + prepareCart(2); + CanteenSpuEntity product = productWithStock(5); + when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(11L))).thenReturn(R.ok(List.of(product))); + + assertDoesNotThrow(() -> ReflectionTestUtils.invokeMethod( + service, "validateCanteenSpuOrderInventory", cashierOrder())); + + verify(remoteCanteenSpuService, never()).updateSkuStockIdempotent(any()); + } + + /** + * 验证聚合码创建前就拒绝已知的库存不足,避免继续展示可付款二维码。 + */ + @Test + void aggregatePreflightRejectsInsufficientStock() { + prepareCart(2); + CanteenSpuEntity product = productWithStock(1); + when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(11L))).thenReturn(R.ok(List.of(product))); + + RuntimeException exception = assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod( + service, "validateCanteenSpuOrderInventory", cashierOrder())); + + assertEquals("测试菜品库存不足", exception.getMessage()); + verify(remoteCanteenSpuService, never()).updateSkuStockIdempotent(any()); + } + + /** + * 验证机器收银退款使用售后单幂等号恢复原购数量,重复回调不会反复增加库存。 + */ + @Test + void cashierRefundRestoresCanteenSpuStockIdempotently() { + prepareCart(2); + CanteenSpuEntity product = productWithStock(3); + when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(11L))).thenReturn(R.ok(List.of(product))); + when(remoteCanteenSpuService.updateSkuStockIdempotent(any())).thenReturn(R.ok()); + + assertDoesNotThrow(() -> service.fastenerInventory(cashierOrder(), null, YesNoEnum.NO, 500L)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class); + verify(remoteCanteenSpuService).updateSkuStockIdempotent(captor.capture()); + CanteenSpuUpdateStockReqDTO request = captor.getValue(); + assertEquals("APPLY", request.getOperation()); + assertEquals("HARD_DAILY_MENU_AFTER_SALE:500", request.getBizId()); + assertEquals(2, request.getItems().get(0).getIncrCount()); + assertEquals(3, request.getItems().get(0).getBeforeStock()); + } + + /** + * 准备机器收银购物车来源和商品数量。 + * + * @param productCount 本次购买份数 + */ + private void prepareCart(int productCount) { + HardParentCarEntity parentCar = HardParentCarEntity.builder() + .id(200L) + .productSource(HardProductSourceEnum.CANTEEN_SPU.getType()) + .build(); + HardCarEntity car = HardCarEntity.builder() + .parentCarId(200L) + .productId(11L) + .productCount(productCount) + .build(); + when(hardCarService.getParentCarById(200L)).thenReturn(parentCar); + when(hardCarService.getByParentCarId(200L)).thenReturn(List.of(car)); + } + + /** + * 构造指定权威库存的当前商户基础菜品。 + * + * @param stock 服务端当前可售库存 + * @return 用于库存校验的基础菜品 + */ + private CanteenSpuEntity productWithStock(int stock) { + CanteenSpuEntity product = new CanteenSpuEntity(); + product.setId(11L); + product.setMerchantId(300L); + product.setName("测试菜品"); + product.setSpuType(1); + product.setStock(stock); + return product; + } + + /** + * 构造机器收银提交的食堂线下订单。 + * + * @return 关联固定父购物车和商户的 BELOW 订单 + */ + private HardOrderEntity cashierOrder() { + return HardOrderEntity.builder() + .id(100L) + .sysType(HardTypeEnum.CANTEEN.getType()) + .orderType(HardOrderTypeEnum.BELOW.getType()) + .parentCarId(200L) + .merchantId(300L) + .build(); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplPaymentCallbackTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplPaymentCallbackTest.java new file mode 100644 index 00000000..edc20a2e --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplPaymentCallbackTest.java @@ -0,0 +1,57 @@ +package com.bsd.pjcloud.hard.service.impl.order; + +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.remote.HardUpdateShopStatusDto; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; +import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 商城订单第三方支付成功回调幂等测试。 + */ +class HardOrderServiceImplPaymentCallbackTest { + + /** 商城订单 Mapper Mock,用于返回已经支付的订单。 */ + private final HardOrderMapper hardOrderMapper = mock(HardOrderMapper.class); + + /** 被测订单服务,仅调用真实状态推进方法。 */ + private final HardOrderServiceImpl service = mock(HardOrderServiceImpl.class, CALLS_REAL_METHODS); + + /** + * 注入 MyBatis-Plus 基类 Mapper。 + */ + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "baseMapper", hardOrderMapper); + } + + /** + * 已支付商城订单收到重复成功通知时应直接返回成功,不再次更新订单或触发订单成功副作用。 + */ + @Test + void repeatedPaySuccessShouldBeIdempotent() { + HardOrderEntity order = new HardOrderEntity(); + order.setId(100L); + order.setOrderType(HardOrderTypeEnum.FARMER.getType()); + order.setOrderStatus(HardOrderStatusEnum.SHOP_WAIT_PAY_SUCCESS.getType()); + when(hardOrderMapper.selectById(order.getId())).thenReturn(order); + HardUpdateShopStatusDto dto = new HardUpdateShopStatusDto(); + dto.setOrderId(order.getId()); + dto.setStatus(HardUpdateShopStatusDto.ChangeShopStatus.PAY_SUCCESS); + + Assertions.assertTrue(service.updateShopStatus(dto)); + + verify(hardOrderMapper, never()).updateById(any(HardOrderEntity.class)); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java index 6e10bd49..e2f3c14c 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java @@ -1,5 +1,7 @@ package com.bsd.pjcloud.hard.service.impl.order; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; @@ -7,18 +9,29 @@ import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum; import com.bsd.pjcloud.hard.api.dto.hard.HandCancelCodeDto; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; +import com.bsd.pjcloud.hard.mapper.order.HardMessageOutboxMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; +import com.bsd.pjcloud.hard.service.HardApptReservationService; +import com.bsd.pjcloud.hard.service.HardBroadcastService; import com.bsd.pjcloud.hard.service.HardCarService; +import com.bsd.pjcloud.wallet.api.feign.RemoteCodeUseService; +import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -35,16 +48,28 @@ class HardOrderServiceImplVerificationTest { /** 订单购物车服务,用于返回退款成功后的剩余份数。 */ private final HardCarService hardCarService = mock(HardCarService.class); + /** 预约二维码远程服务,用于验证订单回退会同步恢复 Wallet 核销码。 */ + private final RemoteCodeUseService remoteCodeUseService = mock(RemoteCodeUseService.class); + + /** 核销消息 Mapper,用于隔离回退时对尚未投递消息的状态调整。 */ + private final HardMessageOutboxMapper hardMessageOutboxMapper = mock(HardMessageOutboxMapper.class); + + /** 取餐广播服务,用于验证食堂预约回退会重新启用待取餐提示。 */ + private final HardBroadcastService hardBroadcastService = mock(HardBroadcastService.class); + /** 被测订单服务,仅执行真实方法,未涉及的庞大依赖保持空值。 */ private final HardOrderServiceImpl service = mock(HardOrderServiceImpl.class, CALLS_REAL_METHODS); /** - * 为被测服务注入旧核销拒绝路径实际会访问的两个依赖。 + * 为被测服务注入核销校验与回退路径实际访问的依赖。 */ @BeforeEach void setUp() { ReflectionTestUtils.setField(service, "baseMapper", hardOrderMapper); ReflectionTestUtils.setField(service, "hardCarService", hardCarService); + ReflectionTestUtils.setField(service, "remoteCodeUseService", remoteCodeUseService); + ReflectionTestUtils.setField(service, "hardMessageOutboxMapper", hardMessageOutboxMapper); + ReflectionTestUtils.setField(service, "hardBroadcastService", hardBroadcastService); } /** @@ -78,6 +103,96 @@ class HardOrderServiceImplVerificationTest { assertEquals("该预约订单商品已全部退款,不能核销", exception.getMessage()); } + /** + * 验证已核销且仍有可履约商品的食堂预约会恢复订单、二维码和取餐广播。 + */ + @Test + void rollbackReserveVerificationRestoresPendingState() { + HardOrderEntity order = reservationOrder(HardRefundStatusEnum.NONE, HardOrderStatusEnum.CANCEL); + HardCarVo remaining = new HardCarVo(); + remaining.setEffectiveProductCount(1); + when(hardOrderMapper.selectByIdForUpdate(1L)).thenReturn(order); + when(hardCarService.getByOrderId(1L)).thenReturn(List.of(remaining)); + when(hardOrderMapper.update(isNull(), any(Wrapper.class))).thenReturn(1); + when(remoteCodeUseService.rollbackReservationCode(1L)).thenReturn(R.ok(true)); + + assertTrue(service.rollbackReserveVerification(1L)); + + verify(remoteCodeUseService).rollbackReservationCode(1L); + verify(hardBroadcastService).rollbackDoneBroadcast(order); + } + + /** + * 验证尚未核销的预约不能执行回退,也不能提前恢复 Wallet 二维码。 + */ + @Test + void rollbackReserveVerificationRejectsPendingOrder() { + when(hardOrderMapper.selectByIdForUpdate(1L)) + .thenReturn(reservationOrder(HardRefundStatusEnum.NONE, HardOrderStatusEnum.PAY)); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> service.rollbackReserveVerification(1L)); + + assertEquals("仅已核销订单支持退回到核销前", exception.getMessage()); + verify(remoteCodeUseService, never()).rollbackReservationCode(anyLong()); + } + + /** + * 验证退款处理中的已核销预约不能恢复为待核销,避免退款与再次履约并发发生。 + */ + @Test + void rollbackReserveVerificationRejectsRefundingOrder() { + when(hardOrderMapper.selectByIdForUpdate(1L)) + .thenReturn(reservationOrder(HardRefundStatusEnum.APPLY, HardOrderStatusEnum.CANCEL)); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> service.rollbackReserveVerification(1L)); + + assertEquals("该预约订单正在退款,不能退回到核销前", exception.getMessage()); + verify(remoteCodeUseService, never()).rollbackReservationCode(anyLong()); + } + + /** + * 验证历史上复用 RESERVE 编码的活动核销订单不会被预约纠错入口误处理。 + */ + @Test + void rollbackReserveVerificationRejectsNonReservationBusiness() { + HardOrderEntity order = reservationOrder(HardRefundStatusEnum.NONE, HardOrderStatusEnum.CANCEL); + order.setSysType(HardTypeEnum.NONE.getType()); + when(hardOrderMapper.selectByIdForUpdate(1L)).thenReturn(order); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> service.rollbackReserveVerification(1L)); + + assertEquals("当前业务类型不支持退回到核销前", exception.getMessage()); + verify(remoteCodeUseService, never()).rollbackReservationCode(anyLong()); + } + + /** + * 旧免审退款规则损坏时只能放弃旧快照,不能阻断独立的过期预约退款快照。 + */ + @Test + void expiredRefundSnapshotDoesNotDependOnLegacyRuleValidity() { + HardOrderEntity order = new HardOrderEntity(); + MchAutoRefundConfigVo config = new MchAutoRefundConfigVo(); + config.setId(7L); + config.setEnabled("1"); + config.setRuleType("BROKEN_RULE"); + config.setExpiredReserveRefundEnabled("1"); + config.setExpiredReserveRefundDelayHours(2); + 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)); + + 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("1", order.getExpiredRefundRestoreInventory()); + } + /** * 构造旧消费机通用核销接口请求。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoteThirdPaymentTaskService.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoteThirdPaymentTaskService.java new file mode 100644 index 00000000..ce21a592 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoteThirdPaymentTaskService.java @@ -0,0 +1,26 @@ +package com.bsd.pjcloud.thirdparty.api.feign; + +import com.bsd.pjcloud.common.core.constant.ServiceNameConstants; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.feign.annotation.NoToken; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPaymentTaskDispatchDto; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPaymentTaskDispatchVo; +import jakarta.validation.Valid; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +/** + * Quartz 调用 thirdparty 支付任务分发器的内部 Feign 契约。 + */ +@FeignClient(contextId = "remoteThirdPaymentTaskService", value = ServiceNameConstants.THIRDPARTY_SERVICE) +public interface RemoteThirdPaymentTaskService { + /** + * 同步请求 thirdparty 接收一批任务,渠道调用由已取得租约的 Worker 异步执行。 + * @param dto 固定任务类型、批量上限和触发来源 + * @return 接收、领取、繁忙和积压统计 + */ + @NoToken + @PostMapping("/third-payment-task/inner/dispatch") + R dispatch(@Valid @RequestBody ThirdPaymentTaskDispatchDto dto); +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/fz/ThirdMchFzRelationSceneEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/fz/ThirdMchFzRelationSceneEnum.java new file mode 100644 index 00000000..2c9648da --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/fz/ThirdMchFzRelationSceneEnum.java @@ -0,0 +1,24 @@ +package com.bsd.pjcloud.thirdparty.constant.fz; + +import lombok.Getter; + +/** + * 商户分账关系业务场景,编码用于区分同一渠道账号未来可能增加的其他分账用途。 + */ +@Getter +public enum ThirdMchFzRelationSceneEnum { + /** 商户支付成功后向平台收取服务费的默认分佣关系。 */ + DEFAULT_PLATFORM_COMMISSION("DEFAULT_PLATFORM_COMMISSION"); + + /** 关系表持久化的稳定业务场景编码。 */ + private final String code; + + /** + * 创建关系场景。 + * + * @param code 持久化场景编码 + */ + ThirdMchFzRelationSceneEnum(String code) { + this.code = code; + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/fz/ThirdMchFzRelationStatusEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/fz/ThirdMchFzRelationStatusEnum.java new file mode 100644 index 00000000..9b40c3eb --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/fz/ThirdMchFzRelationStatusEnum.java @@ -0,0 +1,32 @@ +package com.bsd.pjcloud.thirdparty.constant.fz; + +import lombok.Getter; + +/** + * 商户渠道分账关系状态,编码持久化到 {@code third_mch_fz_relation.status},不可随意修改。 + */ +@Getter +public enum ThirdMchFzRelationStatusEnum { + /** 已建立本地关系事实,等待 Quartz 下发绑定任务。 */ + PENDING_BIND("PENDING_BIND"), + /** Worker 已开始调用渠道,结果不确定时允许使用相同参数重试。 */ + BINDING("BINDING"), + /** 渠道已明确确认分账关系存在,可以创建需要平台抽佣的支付订单。 */ + BOUND("BOUND"), + /** 渠道返回确定性失败,等待修正配置或人工重试。 */ + BIND_FAILED("BIND_FAILED"), + /** 支付配置已变化,该关系不再用于新订单,但为历史订单保留渠道关系和快照。 */ + SUPERSEDED("SUPERSEDED"); + + /** 数据库和内部接口使用的稳定状态编码。 */ + private final String code; + + /** + * 创建关系状态。 + * + * @param code 持久化状态编码 + */ + ThirdMchFzRelationStatusEnum(String code) { + this.code = code; + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPaymentTaskStatusEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPaymentTaskStatusEnum.java new file mode 100644 index 00000000..5dc689cb --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPaymentTaskStatusEnum.java @@ -0,0 +1,35 @@ +package com.bsd.pjcloud.thirdparty.constant.pay; + +import lombok.Getter; + +/** + * 任务执行状态;状态只由任务 Worker 按租约条件推进。 + */ +@Getter +public enum ThirdPaymentTaskStatusEnum { + /** 已创建、等待首次执行。 */ + READY("READY"), + /** 已被某个 Worker 租约领取。 */ + PROCESSING("PROCESSING"), + /** 外部结果未收敛,等待退避重试。 */ + RETRY("RETRY"), + /** 已完成。 */ + SUCCESS("SUCCESS"), + /** 确定性错误终止。 */ + FAILED("FAILED"), + /** 达到告警阈值但仍允许继续重试。 */ + ALERT("ALERT"), + /** 被业务明确取消。 */ + CANCELLED("CANCELLED"); + + /** 任务表持久化编码。 */ + private final String code; + + /** + * 创建任务状态。 + * @param code 持久化编码 + */ + ThirdPaymentTaskStatusEnum(String code) { + this.code = code; + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPaymentTaskTypeEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPaymentTaskTypeEnum.java new file mode 100644 index 00000000..f7c4e940 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPaymentTaskTypeEnum.java @@ -0,0 +1,66 @@ +package com.bsd.pjcloud.thirdparty.constant.pay; + +import lombok.Getter; + +/** + * 第三方支付任务类型;编码持久化到 {@code third_payment_task.task_type},不可随意修改。 + */ +@Getter +public enum ThirdPaymentTaskTypeEnum { + /** 支付订单创建或创建结果补偿。 */ + PAY_CREATE("PAY_CREATE"), + /** App、订单码支付查单。 */ + PAY_QUERY("PAY_QUERY"), + /** 普通退款查单。 */ + REFUND_QUERY("REFUND_QUERY"), + /** 普通退款创建结果补偿。 */ + REFUND_CREATE("REFUND_CREATE"), + /** 微信或支付宝分账创建/阶段推进。 */ + FZ_CREATE("FZ_CREATE"), + /** 支付宝直付通确认结算;只推进确认结算阶段。 */ + FZ_SETTLE_CONFIRM("FZ_SETTLE_CONFIRM"), + /** 支付宝直付通平台抽佣提交;成功受理后必须继续查询。 */ + FZ_ROYALTY("FZ_ROYALTY"), + /** 支付宝直付通平台抽佣结果查询;只有最终成功才完成分账。 */ + FZ_ROYALTY_QUERY("FZ_ROYALTY_QUERY"), + /** 分账查单。 */ + FZ_QUERY("FZ_QUERY"), + /** 分账退款查单。 */ + FZ_REFUND_QUERY("FZ_REFUND_QUERY"), + /** 支付或退款后的业务回调补偿。 */ + BIZ_CALLBACK("BIZ_CALLBACK"), + /** 聚合支付会话补偿。 */ + AGGREGATE_SESSION("AGGREGATE_SESSION"), + /** 历史订单码查单并在渠道确认后关闭。 */ + PAY_CLOSE("PAY_CLOSE"), + /** 历史聚合会话收口;无渠道单时本地关闭,有渠道单时物化唯一会话补偿任务。 */ + AGGREGATE_CLOSE("AGGREGATE_CLOSE"), + /** 对账任务预留类型。 */ + RECONCILE("RECONCILE"); + + /** 任务表持久化编码。 */ + private final String code; + + /** + * 创建任务类型。 + * @param code 持久化编码 + */ + ThirdPaymentTaskTypeEnum(String code) { + this.code = code; + } + + /** + * 按持久化编码解析任务类型。 + * @param code 请求或数据库中的任务编码 + * @return 匹配的任务类型 + * @throws IllegalArgumentException 编码为空或未知时抛出 + */ + public static ThirdPaymentTaskTypeEnum fromCode(String code) { + for (ThirdPaymentTaskTypeEnum value : values()) { + if (value.code.equals(code)) { + return value; + } + } + throw new IllegalArgumentException("不支持的支付任务类型:" + code); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdMiniRemotePayDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdMiniRemotePayDto.java index 9c3bafbf..1dda59d6 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdMiniRemotePayDto.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdMiniRemotePayDto.java @@ -6,6 +6,11 @@ import lombok.Data; @Data public class ThirdMiniRemotePayDto extends ThirdMiniAppPayDto { + /** + * 远程业务订单的可信收款商户 ID,由业务订单服务端读取并传递,禁止由客户端伪造。 + */ + private Long mchId; + /** * 用户id不能为空 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdPaymentTaskDispatchDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdPaymentTaskDispatchDto.java new file mode 100644 index 00000000..4771cb0f --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdPaymentTaskDispatchDto.java @@ -0,0 +1,26 @@ +package com.bsd.pjcloud.thirdparty.dto.pay; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Quartz 分发支付任务的内部请求;不携带订单金额、密钥等敏感业务数据。 + */ +@Data +public class ThirdPaymentTaskDispatchDto { + /** 本次分发动作编码。 */ + @NotBlank(message = "任务动作不能为空") + private String taskType; + /** 最大领取数量。 */ + @Min(value = 1, message = "批量大小必须大于0") + @Max(value = 500, message = "批量大小不能超过500") + private Integer batchSize = 100; + /** 触发来源,固定任务由 Quartz 传入。 */ + private String triggerSource = "QUARTZ"; + /** Quartz 原始触发时间,仅用于调用链诊断;thirdparty 必须使用自身接收时间计算数据库租约。 */ + private LocalDateTime triggerTime; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java new file mode 100644 index 00000000..0584deec --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java @@ -0,0 +1,87 @@ +package com.bsd.pjcloud.thirdparty.entity.pay; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 商户第三方支付分账关系事实,一条记录描述一个 {@code mch_manage} 在一个渠道下的平台分佣接收方。 + */ +@Data +@TenantTable +@TableName("third_mch_fz_relation") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "商户第三方支付分账关系") +public class ThirdMchFzRelationEntity extends Model { + /** 关系主键,由应用侧雪花算法生成。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 关系所属租户ID,来源于 mch_manage,作为数据隔离和任务幂等边界。 */ + private Long tenantId; + /** 对应的 mch_manage 商户主键,不跨租户解释。 */ + private Long mchId; + /** 产生该关系的 mch_pay_setting 主键,用于配置变更时识别历史关系。 */ + private Long paySettingId; + /** 支付平台持久化编码,10表示微信、20表示支付宝。 */ + private String platformType; + /** 本地关系场景编码,本期固定为 DEFAULT_PLATFORM_COMMISSION。 */ + private String relationScene; + /** 微信服务商 AppID 或支付宝平台 AppID 快照,用于区分不同平台账号。 */ + private String channelAppId; + /** 微信 sub_mchid 或支付宝 smid/ip_role_id,用于绑定关系与商户支付配置对应。 */ + private String channelSubMchId; + /** 支付宝命名平台账号键;默认支付宝账号及微信关系为空。 */ + private String accountKey; + /** 渠道接收方类型,微信为 MERCHANT_ID,支付宝为 userId 或 loginName。 */ + private String receiverType; + /** 平台分佣接收账号,发起渠道请求时使用;管理端展示必须脱敏。 */ + private String receiverAccount; + /** 平台分佣接收方名称快照,渠道不要求时允许为空。 */ + private String receiverName; + /** 渠道关系类型,微信默认 SERVICE_PROVIDER,支付宝关系允许为空。 */ + private String channelRelationType; + /** 渠道绑定稳定幂等号;支付宝重试必须保持不变,微信用于本地审计。 */ + private String outRequestNo; + /** 当前关系状态,由关系 Worker 使用原状态条件推进。 */ + private String status; + /** 是否为支付配置当前默认关系,0为历史关系、1为新订单使用的默认关系。 */ + private String isDefault; + /** 脱敏后的绑定请求快照,不得包含 app_auth_token、私钥或公钥。 */ + private String bindReq; + /** 最近一次渠道绑定或关系查询响应快照。 */ + private String bindRes; + /** 最近一次绑定错误摘要,不得包含渠道凭据。 */ + private String bindError; + /** 最近一次渠道结果码或外部状态,用于监控聚合。 */ + private String lastExternalStatus; + /** 最近一次调用渠道绑定或查询接口的时间。 */ + private LocalDateTime lastQueryTime; + /** 渠道明确确认关系已绑定的时间。 */ + private LocalDateTime boundTime; + /** 审计创建人,由 MyBatis 填充器写入。 */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + /** 本地关系事实创建时间。 */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + /** 最近修改人,由 MyBatis 填充器维护。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + /** 最近一次关系状态、默认标记或渠道快照更新时间。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + /** 逻辑删除标记,0为正常、1为删除;历史关系默认保留而不物理删除。 */ + @TableLogic + @TableField(fill = FieldFill.INSERT) + private String delFlag; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdPaymentTaskAttemptEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdPaymentTaskAttemptEntity.java new file mode 100644 index 00000000..8f1b3d87 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdPaymentTaskAttemptEntity.java @@ -0,0 +1,50 @@ +package com.bsd.pjcloud.thirdparty.entity.pay; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 支付任务单次执行日志,只允许保存脱敏快照。 + */ +@Data +@TenantTable +@TableName("third_payment_task_attempt") +@EqualsAndHashCode(callSuper = true) +public class ThirdPaymentTaskAttemptEntity extends Model { + /** 单次执行尝试主键,由应用侧雪花算法生成。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 所属支付任务主键,来源于 {@code third_payment_task.id}。 */ + private Long taskId; + /** 尝试记录所属租户ID,复制自任务事实并参与租户数据隔离。 */ + private Long tenantId; + /** 任务内尝试序号,来源于任务成功领取后的累计次数,从一开始递增。 */ + private Integer attemptNo; + /** 本次执行触发来源,例如 QUARTZ、CALLBACK、SYNC_RESPONSE 或 MANUAL。 */ + private String triggerSource; + /** 实际执行本次尝试的 thirdparty Worker 实例标识,用于多实例故障定位。 */ + private String workerId; + /** 本次执行对应的租约令牌,用于关联任务 CAS 所有权;任务重领后旧令牌不再有效。 */ + private String leaseToken; + /** 本次执行的脱敏请求 JSON;允许为空,禁止保存私钥、公钥、完整授权令牌或付款码。 */ + private String requestSnapshot; + /** 本次执行的脱敏响应 JSON;允许为空,支付凭据及个人敏感字段必须遮蔽。 */ + private String responseSnapshot; + /** 本次执行观察到的渠道或本地编排状态;调用前失败时允许为空。 */ + private String externalStatus; + /** 本次执行的渠道或本地错误码;成功执行时允许为空。 */ + private String errorCode; + /** 本次执行的脱敏错误摘要;成功执行时允许为空,禁止记录敏感配置原文。 */ + private String errorMessage; + /** Worker 开始处理本次尝试的时间,用于计算执行耗时和定位超时租约。 */ + private LocalDateTime startedTime; + /** 本次尝试写入成功、失败或重试结果的时间;Worker 中断且尚未回写时允许为空。 */ + private LocalDateTime finishedTime; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdPaymentTaskEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdPaymentTaskEntity.java new file mode 100644 index 00000000..c2d82513 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdPaymentTaskEntity.java @@ -0,0 +1,82 @@ +package com.bsd.pjcloud.thirdparty.entity.pay; + +import com.baomidou.mybatisplus.annotation.*; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 支付任务事实,保存跨进程恢复所需的稳定业务号、租约和最近错误。 + */ +@Data +@TenantTable +@TableName("third_payment_task") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "第三方支付任务") +public class ThirdPaymentTaskEntity extends Model { + /** 任务事实主键,由应用侧雪花算法生成,不依赖数据库自增。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 任务所属租户ID,来源于业务单据或当前租户上下文,也是任务领取和唯一键隔离边界。 */ + private Long tenantId; + /** 任务类型持久化编码,取值由 {@code ThirdPaymentTaskTypeEnum} 维护,创建后不得改变。 */ + private String taskType; + /** 租户内稳定幂等键,由任务类型和原业务号组成,外部调用重试时不得重新生成。 */ + private String taskKey; + /** 关联支付订单主键;非支付订单类任务允许为空。 */ + private Long payOrderId; + /** 关联分账订单主键;非分账及退分账任务允许为空。 */ + private Long fzOrderId; + /** 关联聚合支付会话主键;非聚合支付任务允许为空。 */ + private Long aggregateSessionId; + /** 平台内稳定支付单号,来源于支付订单;未关联支付订单的任务允许为空。 */ + private String payNo; + /** 平台内稳定退款单号,来源于退款业务;非退款任务允许为空。 */ + private String refundNo; + /** 平台内稳定分账单号,来源于分账订单;非分账任务允许为空。 */ + private String fzNo; + /** 聚合支付会话稳定业务号,来源于聚合会话;非聚合支付任务允许为空。 */ + private String aggregateNo; + /** 当前任务状态持久化编码,由任务状态机通过 CAS 更新,禁止外部直接跳转终态。 */ + private String status; + /** 下一次允许领取的数据库时间;READY/RETRY 到期后才可由 Quartz 分发触发领取。 */ + private LocalDateTime nextRunTime; + /** 同批到期任务的领取优先级,数值越大越先领取。 */ + private Integer priority; + /** 当前租约持有的 thirdparty Worker 实例标识;未领取或任务终态时为空。 */ + private String leaseOwner; + /** 当前执行租约的随机令牌,完成更新必须匹配该值,未领取或任务终态时为空。 */ + private String leaseToken; + /** 当前执行租约到期时间;只约束 Worker 所有权,不代表外部渠道业务超时。 */ + private LocalDateTime leaseExpireTime; + /** 任务累计领取尝试次数,每次 CAS 领取成功后递增,初始值为零。 */ + private Integer attemptCount; + /** 最近一次执行观察到的支付宝、微信或本地编排状态;尚未执行时允许为空。 */ + private String lastExternalStatus; + /** 最近一次失败的渠道或本地错误码;最近执行成功或尚无错误时允许为空。 */ + private String lastErrorCode; + /** 最近一次失败的脱敏错误摘要,不得包含密钥、完整授权令牌或付款码。 */ + private String lastErrorMessage; + /** 进入 SUCCESS、FAILED、ALERT 或 CANCELLED 等终态的时间;处理中任务为空。 */ + private LocalDateTime finishedTime; + /** 审计创建人,由 MyBatis 填充器写入;系统任务允许为空或使用系统身份。 */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + /** 任务事实创建时间,由应用显式写入或 MyBatis 填充器补齐。 */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + /** 最近修改人,由 MyBatis 填充器维护;系统任务允许为空或使用系统身份。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + /** 最近一次任务状态、租约或诊断信息变更时间。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + /** 逻辑删除标记,沿用项目通用 {@code 0/1} 约定;任务状态机不得物理删除任务事实。 */ + @TableLogic + @TableField(fill = FieldFill.INSERT) + private String delFlag; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/wechat/ThirdMiniWechatRefundQueryVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/wechat/ThirdMiniWechatRefundQueryVo.java index fc1917b0..f4eb561c 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/wechat/ThirdMiniWechatRefundQueryVo.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appplus/pay/wechat/ThirdMiniWechatRefundQueryVo.java @@ -91,6 +91,11 @@ public class ThirdMiniWechatRefundQueryVo { */ private Long refund_fee; + /** + * 微信支付 V3 退款查询状态,取值为 SUCCESS、CLOSED、PROCESSING 或 ABNORMAL;旧 V2 字段保持兼容。 + */ + private String refund_status; + public boolean isHttpSucess() { return ObjectUtil.equals(ThirdMiniWechatRefundQueryVo.SUCCESS, err_code) || ObjectUtil.isEmpty(err_code); } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayOrderVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayOrderVo.java index ed1572bc..2fa0ecaa 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayOrderVo.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayOrderVo.java @@ -3,13 +3,110 @@ package com.bsd.pjcloud.thirdparty.vo.pay; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import lombok.Data; +/** + * 第三方支付订单管理端视图,补充商户与分账阶段信息且不暴露商户支付密钥快照。 + */ @Data public class ThirdPayOrderVo extends ThirdPayOrderEntity { + /** + * 支付订单所属商户名称,来源于商户管理服务;商户已删除或不可见时为空。 + */ private String mchName; + /** + * 支付用户姓名,来源于用户服务;匿名支付或用户已不存在时为空。 + */ private String username; + /** + * 支付用户手机号,来源于用户服务;匿名支付或未登记手机号时为空。 + */ private String userPhone; + /** + * 支付宝直付通二级商户标识,来源于订单创建时保存的商户配置快照;非支付宝订单为空。 + */ + private String smid; + + /** + * 分账订单持久化状态编码,沿用 third_fz_order.status;未创建分账记录时为空。 + */ + private String fzStatus; + + /** + * 支付宝确认结算阶段状态;微信为 NOT_APPLICABLE,未启用分账为 NOT_CONFIGURED。 + */ + private String settleConfirmStatus; + + /** + * 平台抽佣阶段状态,取值为 PENDING、PROCESSING、SUCCESS、FAILED 或 NOT_CONFIGURED。 + */ + private String royaltyStatus; + + /** + * 退分账阶段状态,取值为 NOT_STARTED、PROCESSING、SUCCESS、FAILED 或 NOT_CONFIGURED。 + */ + private String fzRefundStatus; + + /** + * 支付宝确认结算请求 JSON;尚未发起或非支付宝订单时为空。 + */ + private String settleConfirmReq; + + /** + * 支付宝确认结算响应 JSON;尚未获得响应或非支付宝订单时为空。 + */ + private String settleConfirmRes; + + /** + * 支付宝确认结算错误 JSON;无错误或非支付宝订单时为空。 + */ + private String settleConfirmError; + + /** + * 平台抽佣请求 JSON,支付宝和微信均从结构化分账快照读取。 + */ + private String royaltyReq; + + /** + * 平台抽佣响应 JSON,支付宝和微信均从结构化分账快照读取。 + */ + private String royaltyRes; + + /** + * 平台抽佣错误 JSON;无错误时为空。 + */ + private String royaltyError; + + /** + * 退分账请求 JSON,来源于分账退款阶段快照或兼容的历史请求字段。 + */ + private String fzRefundReq; + + /** + * 退分账响应 JSON,来源于分账退款阶段快照或兼容的历史响应字段。 + */ + private String fzRefundRes; + + /** + * 退分账错误 JSON;无错误时为空。 + */ + private String fzRefundError; + + /** 当前支付主任务状态,来源于 third_payment_task;无任务时为空。 */ + private String taskStatus; + + /** 当前支付主任务主键,管理端人工重试必须使用该 ID 而不是支付订单主键。 */ + private Long taskId; + + /** 当前支付主任务下一次执行时间,来源于任务表。 */ + private java.time.LocalDateTime taskNextRunTime; + + /** 当前支付主任务累计尝试次数。 */ + private Integer taskAttemptCount; + + /** 当前支付主任务最近错误摘要,已限制长度且不含密钥。 */ + private String taskLastError; + } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPaymentTaskDispatchVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPaymentTaskDispatchVo.java new file mode 100644 index 00000000..d7633608 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPaymentTaskDispatchVo.java @@ -0,0 +1,22 @@ +package com.bsd.pjcloud.thirdparty.vo.pay; + +import lombok.Builder; +import lombok.Data; + +/** + * thirdparty 任务分发结果,供 Quartz 判断服务是否接受本轮触发。 + */ +@Data +@Builder +public class ThirdPaymentTaskDispatchVo { + /** 已接收任务数。 */ + private int acceptedCount; + /** 成功取得租约的任务数。 */ + private int claimedCount; + /** 当前 thirdparty 实例正在执行及已进入有界队列的任务数量。 */ + private int processingCount; + /** 队列是否达到容量上限。 */ + private boolean busy; + /** 当前待执行任务积压量。 */ + private long backlogCount; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java index e3b725ef..e611f408 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java @@ -4,6 +4,9 @@ import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +import java.util.LinkedHashMap; +import java.util.Map; + /** * 小程序平台配置 */ @@ -143,6 +146,26 @@ public class AppPlusPayConfig { */ private String subscribeUrl; + /** + * 可选的平台支付宝命名账号,键由商户支付配置中的 accountKey 引用;空配置仍使用当前账号本身。 + */ + private Map accounts = new LinkedHashMap<>(); + + /** + * 直付通平台抽佣接收支付宝账号,类型使用 userId 或 loginName。 + */ + private String royaltyTransIn; + + /** + * 直付通平台抽佣接收账号类型,支付宝支持 userId 或 loginName。 + */ + private String royaltyTransInType; + + /** + * 直付通平台抽佣接收方名称,用于支付宝分账请求描述。 + */ + private String royaltyTransInName; + } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayOrderController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayOrderController.java index 388988b5..cf507769 100755 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayOrderController.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayOrderController.java @@ -2,6 +2,7 @@ package com.bsd.pjcloud.thirdparty.controller.pay; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.common.core.util.R; @@ -41,7 +42,9 @@ public class ThirdPayOrderController { @GetMapping("/page") @PreAuthorize("@pms.hasPermission('third_thirdPayOrder_view')") public R> getThirdPayOrderPage(@ParameterObject Page page, @ParameterObject ThirdPayOrderDto dto) { - return R.ok(thirdPayOrderService.scopePage(page, dto, null)); + Page result = thirdPayOrderService.scopePage(page, dto, null); + result.getRecords().forEach(this::sanitizeForClient); + return R.ok(result); } /** @@ -50,8 +53,8 @@ public class ThirdPayOrderController { @Operation(summary = "通过id查询", description = "通过id查询") @GetMapping("/{id}") @PreAuthorize("@pms.hasPermission('third_thirdPayOrder_view')") - public R getById(@PathVariable("id") Long id) { - return R.ok(thirdPayOrderService.getById(id)); + public R getById(@PathVariable("id") Long id) { + return R.ok(sanitizeForClient(thirdPayOrderService.getById(id))); } /** @@ -94,7 +97,44 @@ public class ThirdPayOrderController { @GetMapping("/export") @PreAuthorize("@pms.hasPermission('third_thirdPayOrder_export')") public List export(ThirdPayOrderEntity thirdPayOrder, Long[] ids) { - return thirdPayOrderService.list(Wrappers.lambdaQuery(thirdPayOrder).in(ArrayUtil.isNotEmpty(ids), ThirdPayOrderEntity::getId, ids)); + List orders = thirdPayOrderService.list( + Wrappers.lambdaQuery(thirdPayOrder).in(ArrayUtil.isNotEmpty(ids), ThirdPayOrderEntity::getId, ids)); + orders.forEach(this::sanitizeForClient); + return orders; } -} \ No newline at end of file + /** + * 清除仅供支付内部使用的商户配置快照,并隐藏可能携带完整支付宝授权令牌的渠道快照。 + * + *

支付宝 App 下单串由 SDK 生成,配置了代调用授权时可能包含 {@code app_auth_token}。 + * 整段隐藏可以避免部分替换破坏签名串后仍遗漏编码形式的令牌;数据库原值不受影响。

+ * + * @param order 即将返回管理端的支付订单;允许为空 + * @param 支付订单实体或管理端视图类型 + * @return 已就地脱敏的同一对象;输入为空时返回空 + */ + private T sanitizeForClient(T order) { + if (order == null) { + return null; + } + order.setMchPaySettingJson(null); + order.setPayRep(hideSnapshotContainingAuthToken(order.getPayRep())); + order.setPayRes(hideSnapshotContainingAuthToken(order.getPayRes())); + order.setPayCallbackRes(hideSnapshotContainingAuthToken(order.getPayCallbackRes())); + order.setRefundRep(hideSnapshotContainingAuthToken(order.getRefundRep())); + order.setRefundRes(hideSnapshotContainingAuthToken(order.getRefundRes())); + order.setRefundCallbackRes(hideSnapshotContainingAuthToken(order.getRefundCallbackRes())); + return order; + } + + /** + * 检查渠道快照是否包含支付宝代调用授权参数;命中时不向浏览器或导出文件返回整段内容。 + * + * @param snapshot 支付或退款请求、响应、回调快照 + * @return 不含授权参数时返回原值,命中授权参数时返回空 + */ + private String hideSnapshotContainingAuthToken(String snapshot) { + return StrUtil.containsIgnoreCase(snapshot, "app_auth_token") ? null : snapshot; + } + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPaymentTaskController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPaymentTaskController.java new file mode 100644 index 00000000..fabd639d --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPaymentTaskController.java @@ -0,0 +1,67 @@ +package com.bsd.pjcloud.thirdparty.controller.pay; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.security.annotation.Inner; +import com.bsd.pjcloud.common.feign.annotation.NoToken; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPaymentTaskDispatchDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPaymentTaskEntity; +import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskService; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPaymentTaskDispatchVo; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +/** + * 支付任务内部调度与管理接口。 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/third-payment-task") +public class ThirdPaymentTaskController { + /** 支付任务服务。 */ + private final ThirdPaymentTaskService taskService; + /** + * Quartz 唯一周期分发入口,必须携带内部调用标识。 + * @param dto 固定任务类型和批量上限 + * @return thirdparty同步接受结果 + */ + @Inner + @NoToken + @PostMapping("/inner/dispatch") + public R dispatch(@Valid @RequestBody ThirdPaymentTaskDispatchDto dto) { + return R.ok(taskService.dispatch(dto)); + } + + /** + * 分页查看任务状态和最近错误,不返回执行尝试中的渠道敏感报文。 + * @param page 分页参数 + * @param task 支持按任务类型、状态和业务号等值筛选 + * @return 当前租户任务分页 + */ + @Operation(summary = "支付任务分页") + @GetMapping("/page") + @PreAuthorize("@pms.hasPermission('third_thirdPayOrder_view')") + public R> page(Page page, ThirdPaymentTaskEntity task) { + return R.ok(taskService.page(page, new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(StrUtil.isNotBlank(task.getTaskType()), ThirdPaymentTaskEntity::getTaskType, task.getTaskType()) + .eq(StrUtil.isNotBlank(task.getStatus()), ThirdPaymentTaskEntity::getStatus, task.getStatus()) + .eq(StrUtil.isNotBlank(task.getPayNo()), ThirdPaymentTaskEntity::getPayNo, task.getPayNo()) + .orderByDesc(ThirdPaymentTaskEntity::getCreateTime))); + } + + /** + * 人工重试原任务,保留原任务键、支付号、退款号和分账号。 + * @param id 任务主键 + * @return 状态允许重试且更新成功时为 true + */ + @Operation(summary = "人工重试支付任务") + @PostMapping("/{id}/retry") + @PreAuthorize("@pms.hasPermission('third_thirdPayOrder_edit')") + public R retry(@PathVariable Long id) { + return R.ok(taskService.manualRetry(id)); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJob.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJob.java index 56c82b9a..28cf6ebb 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJob.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJob.java @@ -2,26 +2,21 @@ package com.bsd.pjcloud.thirdparty.job; import cn.hutool.core.util.ObjectUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.bsd.pjcloud.common.data.tenant.TenantBroker; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum; -import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; -import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService; import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService; -import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; import com.bsd.pjcloud.thirdparty.service.ThirdPayPollingCoordinator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.util.List; /** - * 支付宝与微信聚合订单码状态轮询和到期关闭任务。 + * 支付宝与微信聚合订单码处理器。周期入口已迁移到 Quartz 支付任务表。 */ @Component @Slf4j @@ -49,24 +44,16 @@ public class ThirdAggregateOrderPayJob { private final ThirdAggregateOrderPayService aggregateOrderPayService; /** - * 第三方支付子订单服务,用于在一小时硬截止后仅做本地关闭标记。 - */ - private final ThirdPayOrderService thirdPayOrderService; - - /** - * 服务端轮询协调器,用于在无活动会话时退避数据库扫描,并在新会话创建后恢复短间隔。 + * 旧兼容入口的进程内门控器;不创建线程,也不是支付周期调度来源。 */ private final ThirdPayPollingCoordinator pollingCoordinator; /** * 分批处理等待扫码、平台创建中和支付中的聚合会话。 * - *

调度线程初始没有租户上下文,单条处理前使用记录自身 tenantId 恢复上下文; - * Redis 键仍由公共基础设施自动附加租户前缀。方法每五秒轻量触发一次,但连续空结果时协调器会把 - * 真正的数据库扫描逐级退避到最长六十秒;创建超过一小时的记录不再进入高频处理。

+ *

该方法没有 {@code @Scheduled},仅保留为显式诊断兼容入口。单条处理前使用记录自身 tenantId + * 恢复租户上下文;生产环境由 Quartz 分发任务表记录,不调用本方法形成 thirdparty 兜底调度。

*/ - @Scheduled(fixedDelay = ThirdPayPollingCoordinator.ACTIVE_TRIGGER_INTERVAL_MILLIS, - initialDelay = ThirdPayPollingCoordinator.ACTIVE_TRIGGER_INTERVAL_MILLIS) public void handlePendingSessions() { if (!pollingCoordinator.tryStartAggregatePendingPolling()) { return; @@ -105,14 +92,12 @@ public class ThirdAggregateOrderPayJob { } /** - * 补偿处理已到期但子订单尚未确认关闭的聚合会话,并关闭超过一小时的历史记录。 + * 补偿处理已到期但子订单尚未确认关闭的聚合会话,并继续核对超过一小时的历史记录。 * *

补偿窗口从会话原始失效时间开始计算,最多持续十分钟;因此客户端退出、断网或不再查询状态时, - * 服务端仍会独立完成有限次查单和关单。连续空结果时数据库扫描从三十秒逐级退避到最长五分钟, - * 超过一小时后只把会话及未支付子订单标记为关闭,不再请求支付宝或微信。

+ * 服务端仍会独立完成查单和关单。该兼容方法没有 {@code @Scheduled},周期分发完全由 Quartz 和 + * {@code third_payment_task} 承担;存在渠道子订单的历史记录也不能本地盲关。

*/ - @Scheduled(fixedDelay = ThirdPayPollingCoordinator.COMPENSATION_TRIGGER_INTERVAL_MILLIS, - initialDelay = ThirdPayPollingCoordinator.COMPENSATION_TRIGGER_INTERVAL_MILLIS) public void handleExpiredSessionCompensation() { if (!pollingCoordinator.tryStartAggregateCompensationPolling()) { return; @@ -162,10 +147,10 @@ public class ThirdAggregateOrderPayJob { } /** - * 将超过一小时的过期聚合会话及其未支付子订单标记为交易关闭。 + * 处理超过一小时仍未收敛的聚合会话。 * - *

该分支不调用任何支付平台接口,也不再根据子订单恢复会话。会话先进入关闭终态,随后仅把仍为 - * 未支付的子订单标记为关闭;已经落库的真实支付结果保留给人工对账,但不会重新激活聚合会话。

+ *

方法只保留为旧诊断入口,不承担周期调度。每条记录仍交给聚合支付编排服务,后者对有渠道子单的 + * 会话执行查单或关单,对未知结果抛出异常并保留待处理状态。

* * @param sessions 超过一小时且仍处于活动或过期状态的聚合会话 */ @@ -180,16 +165,7 @@ public class ThirdAggregateOrderPayJob { } try { TenantBroker.runAs(session.getTenantId(), tenantId -> { - aggregatePaySessionService.markTerminal( - session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED); - ThirdPayOrderEntity payOrder = session.getPayNo() == null - ? null : thirdPayOrderService.getByPayNo(session.getPayNo()); - if (payOrder != null) { - thirdPayOrderService.update(new LambdaUpdateWrapper() - .eq(ThirdPayOrderEntity::getId, payOrder.getId()) - .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType()) - .set(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_CLOSED.getType())); - } + aggregateOrderPayService.processPendingSession(session.getId()); }); } catch (Exception exception) { log.warn("关闭历史聚合支付会话失败,sessionId:{},tenantId:{},exceptionType:{}", diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJob.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJob.java index 3224b664..d2ea8ac1 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJob.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJob.java @@ -7,22 +7,26 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.data.tenant.TenantBroker; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity; +import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService; import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusService; +import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatFzService; +import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; import com.bsd.pjcloud.thirdparty.service.ThirdPayPollingCoordinator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.util.List; /** - * 第三方支付订单定时处理任务,负责补偿业务回调及轮询支付宝、微信订单码状态。 + * 第三方支付订单处理器。周期入口已迁移到 Quartz 支付任务表,本类只保留既有批处理方法供诊断兼容。 */ @Component @Slf4j @@ -34,21 +38,21 @@ public class ThirdAppPlusPayJob { */ private static final int ORDER_CODE_BATCH_SIZE = 100; + /** + * 单轮 App 支付、退款和分账补偿最大处理数量,避免第三方网络调用占满调度线程。 + */ + private static final int PAYMENT_COMPENSATION_BATCH_SIZE = 100; + + /** + * 支付成功后等待资金进入可结算状态的最短时间,单位秒。 + */ + private static final long FZ_MIN_DELAY_SECONDS = 30L; + /** * 原生订单码到期后的关单补偿窗口,单位分钟;超过窗口后停止后台查询。 */ private static final long ORDER_CODE_CLOSE_COMPENSATION_MINUTES = 10L; - /** - * 超过历史恢复时间的原生订单码本地关闭标记周期,单位毫秒。 - */ - private static final long ORDER_CODE_HISTORY_CLOSE_INTERVAL_MILLIS = 300000L; - - /** - * 服务启动后首次标记历史订单码的等待时间,单位毫秒。 - */ - private static final long ORDER_CODE_HISTORY_CLOSE_INITIAL_DELAY_MILLIS = 60000L; - /** * 支付订单服务,用于分批读取仍未支付的订单码记录。 */ @@ -60,42 +64,53 @@ public class ThirdAppPlusPayJob { private final ThirdAppPlusService thirdAppPlusService; /** - * 服务端轮询协调器,用于在无待处理订单时退避数据库扫描,并在创建订单后恢复短间隔。 + * 分账订单服务,用于扫描微信及支付宝待确认的分账状态。 + */ + private final ThirdFzOrderService thirdFzOrderService; + + /** + * 微信分账服务,用于创建及查询分账、分账退款。 + */ + private final ThirdAppPlusWechatFzService thirdAppPlusWechatFzService; + + /** + * 支付宝服务,用于直付通确认结算和平台抽佣补偿。 + */ + private final ThirdAppPlusAliPayService thirdAppPlusAliPayService; + + /** + * 旧兼容入口的进程内门控器;不创建线程,也不承担生产支付调度。 */ private final ThirdPayPollingCoordinator pollingCoordinator; /** - * 处理,未勾起业务回调的订单 + * 补偿支付或退款平台终态已经落库、但跨服务业务后置尚未完成的订单。 + * + *

不限制创建时间,确保服务长期停机后仍能恢复;单轮限制数量并在订单租户上下文中执行。

*/ public void handleUnBizCallbackOrder() { - LocalDateTime now = LocalDateTime.now(); - - // 查询10分钟前未出发业务回调的订单 List list = thirdPayOrderService.list(new LambdaQueryWrapper() - .eq(ThirdPayOrderEntity::getIsBizCallback, YesNoEnum.NO.getCode()) - .in(ThirdPayOrderEntity::getStatus, List.of( - ThirdPayOrderStatusEnum.PAY_SUCCESS.getType(), - ThirdPayOrderStatusEnum.REFUNDING.getType(), - ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType(), - ThirdPayOrderStatusEnum.REFUND_FAILED.getType() - )) - .between(ThirdPayOrderEntity::getCreateTime, now.minusMinutes(11), now) - ); - if (ObjectUtil.isEmpty(list)) return; + .and(wrapper -> wrapper.isNull(ThirdPayOrderEntity::getIsBizCallback) + .or().eq(ThirdPayOrderEntity::getIsBizCallback, YesNoEnum.NO.getCode())) + .in(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_SUCCESS.getType(), + ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType()) + .orderByAsc(ThirdPayOrderEntity::getUpdateTime) + .last("limit " + PAYMENT_COMPENSATION_BATCH_SIZE)); for (ThirdPayOrderEntity item : list) { - thirdAppPlusService.queryPayResult(item); + runWithOrderTenant(item, () -> { + ThirdPayOrderEntity latest = thirdPayOrderService.getById(item.getId()); + thirdAppPlusService.queryPayResult(latest); + touchPendingBusinessCallback(item.getId(), item.getStatus()); + }, "业务回调补偿"); } } /** * 分批处理支付宝和微信云消费机订单码,最长处理到订单过期后十分钟。 * - *

初始查询在线程无租户上下文时跨租户读取,处理单条订单前通过 TenantBroker - * 恢复订单所属租户,确保后续读取和原子更新都受正确租户条件约束。方法每五秒轻量触发一次, - * 但连续空结果时协调器会把真正的数据库扫描逐级退避到最长六十秒。

+ *

该方法没有 {@code @Scheduled},仅保留为显式诊断兼容入口。处理单条订单前通过 TenantBroker + * 恢复订单所属租户;生产周期调度只由 Quartz 分发 {@code third_payment_task}。

*/ - @Scheduled(fixedDelay = ThirdPayPollingCoordinator.ACTIVE_TRIGGER_INTERVAL_MILLIS, - initialDelay = ThirdPayPollingCoordinator.ACTIVE_TRIGGER_INTERVAL_MILLIS) public void handleOrderCodeOrders() { if (!pollingCoordinator.tryStartOrderCodePolling()) { return; @@ -140,13 +155,299 @@ public class ThirdAppPlusPayJob { } /** - * 将超过一小时仍未支付的原生支付宝、微信订单码标记为交易关闭。 - * - *

该任务只执行带未支付状态条件的数据库更新,不再向支付宝或微信发起查单、撤销或关单请求; - * 平台若在硬截止后出现延迟支付,由人工渠道核对处理。

+ * 轮询 App 支付订单,覆盖微信或支付宝回调丢失、服务重启及多实例部署场景。 + */ + public void handleAppPaymentOrders() { + if (!pollingCoordinator.tryStartAppPaymentPolling()) { + return; + } + boolean hasPendingOrders = true; + try { + List orders = thirdPayOrderService.list(new LambdaQueryWrapper() + .in(ThirdPayOrderEntity::getPayPlatformType, + AppPlusPlatformTypeEnum.ALI_PAY.getType(), AppPlusPlatformTypeEnum.WECHAT.getType()) + .in(ThirdPayOrderEntity::getBizType, List.of( + ThirdPayOrderBizTypeEnum.APP_SHOP_PAY.getType(), + ThirdPayOrderBizTypeEnum.MINI_APP_WALLET_RECHARGE.getType(), + ThirdPayOrderBizTypeEnum.GUARDIAN_WARD_WALLET_RECHARGE.getType())) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType()) + .orderByAsc(ThirdPayOrderEntity::getUpdateTime) + .last("limit " + PAYMENT_COMPENSATION_BATCH_SIZE)); + hasPendingOrders = ObjectUtil.isNotEmpty(orders); + for (ThirdPayOrderEntity order : orders) { + runWithOrderTenant(order, () -> { + ThirdPayOrderEntity latest = thirdPayOrderService.getById(order.getId()); + if (latest != null && ObjectUtil.equals(latest.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) { + thirdAppPlusService.queryPayResult(latest); + touchPendingPayment(order.getId()); + } + }, "App支付查单"); + } + } finally { + pollingCoordinator.finishAppPaymentPolling(hasPendingOrders); + } + } + + /** + * 轮询微信和支付宝普通退款状态;支付宝退款至少间隔十秒后查询,避免把受理响应误判为终态。 + */ + public void handleRefundOrders() { + if (!pollingCoordinator.tryStartRefundPolling()) { + return; + } + boolean hasPendingRefunds = true; + try { + List orders = thirdPayOrderService.list(new LambdaQueryWrapper() + .in(ThirdPayOrderEntity::getPayPlatformType, + AppPlusPlatformTypeEnum.WECHAT.getType(), AppPlusPlatformTypeEnum.ALI_PAY.getType()) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.REFUNDING.getType()) + .isNotNull(ThirdPayOrderEntity::getRefundNo) + .le(ThirdPayOrderEntity::getRefundTime, LocalDateTime.now().minusSeconds(10)) + .orderByAsc(ThirdPayOrderEntity::getUpdateTime) + .last("limit " + PAYMENT_COMPENSATION_BATCH_SIZE)); + hasPendingRefunds = ObjectUtil.isNotEmpty(orders); + for (ThirdPayOrderEntity order : orders) { + runWithOrderTenant(order, () -> { + thirdAppPlusService.queryRefundResult(thirdPayOrderService.getById(order.getId())); + touchPendingRefund(order.getId(), order.getRefundNo()); + }, "退款查单"); + } + } finally { + pollingCoordinator.finishRefundPolling(hasPendingRefunds); + } + } + + /** + * 创建支付成功后缺失的分账订单,并轮询微信分账、微信分账退款及支付宝直付通两阶段状态。 + */ + public void handleFzOrders() { + if (!pollingCoordinator.tryStartFzPolling()) { + return; + } + boolean hasPendingFz = true; + try { + createMissingFzOrders(); + reconcileSuccessfulFzOrders(); + List fzOrders = thirdFzOrderService.list(new LambdaQueryWrapper() + .in(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_UNPAID.getType(), + ThirdFzOrderStatusEnum.FZ_REFUNDING.getType()) + .orderByAsc(ThirdFzOrderEntity::getUpdateTime) + .last("limit " + PAYMENT_COMPENSATION_BATCH_SIZE)); + hasPendingFz = ObjectUtil.isNotEmpty(fzOrders); + for (ThirdFzOrderEntity fzOrder : fzOrders) { + if (fzOrder.getTenantId() == null) { + log.warn("分账订单缺少租户ID,fzOrderId:{}", fzOrder.getId()); + continue; + } + try { + TenantBroker.runAs(fzOrder.getTenantId(), tenantId -> pollFzOrder(fzOrder.getId())); + } catch (Exception exception) { + log.warn("分账轮询异常,fzOrderId:{},exceptionType:{}", fzOrder.getId(), exception.getClass().getSimpleName()); + } + } + } finally { + pollingCoordinator.finishFzPolling(hasPendingFz); + } + } + + /** + * 为支付成功满 30 秒且尚无分账记录的订单创建稳定分账请求。 + */ + private void createMissingFzOrders() { + List orders = thirdPayOrderService.list(new LambdaQueryWrapper() + .in(ThirdPayOrderEntity::getPayPlatformType, + AppPlusPlatformTypeEnum.ALI_PAY.getType(), AppPlusPlatformTypeEnum.WECHAT.getType()) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()) + .eq(ThirdPayOrderEntity::getEnableFz, YesNoEnum.YES.getCode()) + .le(ThirdPayOrderEntity::getPayTime, LocalDateTime.now().minusSeconds(FZ_MIN_DELAY_SECONDS)) + .notExists("SELECT 1 FROM third_fz_order f WHERE f.pay_no = third_pay_order.pay_no AND f.del_flag = '0'") + .orderByAsc(ThirdPayOrderEntity::getUpdateTime) + .last("limit " + PAYMENT_COMPENSATION_BATCH_SIZE)); + for (ThirdPayOrderEntity order : orders) { + runWithOrderTenant(order, () -> { + ThirdPayOrderEntity latest = thirdPayOrderService.getById(order.getId()); + if (latest == null || thirdFzOrderService.getByPayNo(latest.getPayNo()) != null) { + return; + } + if (ObjectUtil.equals(latest.getPayPlatformType(), AppPlusPlatformTypeEnum.WECHAT.getType())) { + thirdAppPlusWechatFzService.create(latest); + } else { + thirdAppPlusAliPayService.processDirectPayLifecycle(latest); + } + if (thirdFzOrderService.getByPayNo(latest.getPayNo()) == null) { + touchPayOrderAwaitingFzCreation(latest.getId()); + } + }, "创建分账订单"); + } + } + + /** + * 补偿分账终态已落库但支付订单最终抽佣标记尚未写入的窄窗口。 + * + *

分账记录与支付订单不在同一条原子更新中,多实例或进程退出可能发生在两次写入之间。 + * 本方法只信任 {@code third_fz_order} 的最终成功状态,并使用支付订单原状态条件重复写入, + * 不会重新发起支付宝或微信分账请求。

+ */ + private void reconcileSuccessfulFzOrders() { + List orders = thirdPayOrderService.list(new LambdaQueryWrapper() + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()) + .eq(ThirdPayOrderEntity::getEnableFz, YesNoEnum.YES.getCode()) + .and(wrapper -> wrapper.isNull(ThirdPayOrderEntity::getIsFzSuccess) + .or().ne(ThirdPayOrderEntity::getIsFzSuccess, YesNoEnum.YES.getCode())) + .exists("SELECT 1 FROM third_fz_order f WHERE f.pay_no = third_pay_order.pay_no AND f.del_flag = '0' AND f.status = '" + + ThirdFzOrderStatusEnum.FZ_SUCCESS.getType() + "'") + .orderByAsc(ThirdPayOrderEntity::getUpdateTime) + .last("limit " + PAYMENT_COMPENSATION_BATCH_SIZE)); + for (ThirdPayOrderEntity order : orders) { + runWithOrderTenant(order, () -> { + ThirdPayOrderEntity latest = thirdPayOrderService.getById(order.getId()); + if (latest == null || ObjectUtil.equals(latest.getIsFzSuccess(), YesNoEnum.YES.getCode())) { + return; + } + ThirdFzOrderEntity fzOrder = thirdFzOrderService.getByPayNo(latest.getPayNo()); + if (fzOrder == null || ObjectUtil.notEqual(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_SUCCESS.getType())) { + return; + } + ThirdPayOrderEntity update = new ThirdPayOrderEntity(); + update.setFzPrice(fzOrder.getPrice()); + update.setIsFzSuccess(YesNoEnum.YES.getCode()); + thirdPayOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, latest.getId()) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()) + .and(wrapper -> wrapper.isNull(ThirdPayOrderEntity::getIsFzSuccess) + .or().ne(ThirdPayOrderEntity::getIsFzSuccess, YesNoEnum.YES.getCode()))); + }, "分账成功标记补偿"); + } + } + + /** + * 按分账订单平台查询并推进状态。 + * + * @param fzOrderId 分账订单主键 + */ + private void pollFzOrder(Long fzOrderId) { + ThirdFzOrderEntity fzOrder = thirdFzOrderService.getById(fzOrderId); + if (fzOrder == null) { + return; + } + ThirdPayOrderEntity payOrder = thirdPayOrderService.getByPayNo(fzOrder.getPayNo()); + if (payOrder == null) { + return; + } + if (ObjectUtil.equals(payOrder.getPayPlatformType(), AppPlusPlatformTypeEnum.WECHAT.getType())) { + if (ObjectUtil.equals(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_REFUNDING.getType())) { + thirdAppPlusWechatFzService.pollFzRefundOrder(fzOrder); + } else { + thirdAppPlusWechatFzService.pollFzOrder(fzOrder); + } + } else if (List.of(ThirdFzOrderStatusEnum.FZ_UNPAID.getType(), + ThirdFzOrderStatusEnum.FZ_REFUNDING.getType()).contains(fzOrder.getStatus())) { + thirdAppPlusAliPayService.pollDirectPayLifecycle(fzOrder, payOrder); + } + touchPendingFzOrder(fzOrder.getId(), fzOrder.getStatus()); + } + + /** + * 将仍未支付的 App 订单移动到下一轮批次队尾,避免长期 NOTPAY 记录阻塞较新的订单。 + * + * @param orderId 支付订单主键 + */ + private void touchPendingPayment(Long orderId) { + ThirdPayOrderEntity update = new ThirdPayOrderEntity(); + update.setUpdateTime(LocalDateTime.now()); + thirdPayOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, orderId) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType())); + } + + /** + * 将仍在退款中的订单移动到批次队尾,退款号条件确保不会触碰并发发起的另一笔退款。 + * + * @param orderId 支付订单主键 + * @param refundNo 本轮查询使用的稳定退款号 + */ + private void touchPendingRefund(Long orderId, String refundNo) { + ThirdPayOrderEntity update = new ThirdPayOrderEntity(); + update.setUpdateTime(LocalDateTime.now()); + thirdPayOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, orderId) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.REFUNDING.getType()) + .eq(ThirdPayOrderEntity::getRefundNo, refundNo)); + } + + /** + * 将业务回调仍未完成的终态订单移动到队尾,使单个跨服务故障不会占满固定批次。 + * + * @param orderId 支付订单主键 + * @param status 本轮读取的支付或退款成功状态 + */ + private void touchPendingBusinessCallback(Long orderId, String status) { + ThirdPayOrderEntity update = new ThirdPayOrderEntity(); + update.setUpdateTime(LocalDateTime.now()); + thirdPayOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, orderId) + .eq(ThirdPayOrderEntity::getStatus, status) + .and(wrapper -> wrapper.isNull(ThirdPayOrderEntity::getIsBizCallback) + .or().eq(ThirdPayOrderEntity::getIsBizCallback, YesNoEnum.NO.getCode()))); + } + + /** + * 分账记录因历史配置异常尚未创建时轮转支付订单,防止它持续占据缺失分账记录扫描首批。 + * + * @param orderId 支付订单主键 + */ + private void touchPayOrderAwaitingFzCreation(Long orderId) { + ThirdPayOrderEntity update = new ThirdPayOrderEntity(); + update.setUpdateTime(LocalDateTime.now()); + thirdPayOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, orderId) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()) + .eq(ThirdPayOrderEntity::getEnableFz, YesNoEnum.YES.getCode())); + } + + /** + * 将仍处于同一处理中状态的分账记录移动到队尾;终态更新会因原状态条件不匹配而保持不变。 + * + * @param fzOrderId 分账订单主键 + * @param status 本轮读取的待分账或退分账状态 + */ + private void touchPendingFzOrder(Long fzOrderId, String status) { + if (!List.of(ThirdFzOrderStatusEnum.FZ_UNPAID.getType(), + ThirdFzOrderStatusEnum.FZ_REFUNDING.getType()).contains(status)) { + return; + } + ThirdFzOrderEntity update = new ThirdFzOrderEntity(); + update.setUpdateTime(LocalDateTime.now()); + thirdFzOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdFzOrderEntity::getId, fzOrderId) + .eq(ThirdFzOrderEntity::getStatus, status)); + } + + /** + * 在支付订单所属租户上下文中执行补偿逻辑。 + * + * @param order 支付订单 + * @param action 补偿动作 + * @param actionName 日志动作名称 + */ + private void runWithOrderTenant(ThirdPayOrderEntity order, Runnable action, String actionName) { + if (order.getTenantId() == null) { + log.warn("{}缺少租户ID,orderId:{}", actionName, order.getId()); + return; + } + try { + TenantBroker.runAs(order.getTenantId(), tenantId -> action.run()); + } catch (Exception exception) { + log.warn("{}异常,orderId:{},exceptionType:{}", actionName, order.getId(), exception.getClass().getSimpleName()); + } + } + + /** + * 核对超过一小时仍未支付的原生支付宝、微信订单码。 + * + *

该方法没有 {@code @Scheduled},只保留为显式诊断入口。历史年龄不能替代渠道终态, + * 因此仍复用支付编排服务按订单配置快照查单或关单,未知结果保持未支付。

*/ - @Scheduled(fixedDelay = ORDER_CODE_HISTORY_CLOSE_INTERVAL_MILLIS, - initialDelay = ORDER_CODE_HISTORY_CLOSE_INITIAL_DELAY_MILLIS) public void closeHistoricalOrderCodeOrders() { LocalDateTime cutoff = LocalDateTime.now() .minusMinutes(ThirdPayPollingCoordinator.MAX_AUTO_PROCESSING_MINUTES); @@ -169,15 +470,13 @@ public class ThirdAppPlusPayJob { continue; } try { - TenantBroker.runAs(order.getTenantId(), tenantId -> thirdPayOrderService.update( - new LambdaUpdateWrapper() - .eq(ThirdPayOrderEntity::getId, order.getId()) - .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType()) - .and(timeWrapper -> timeWrapper - .isNull(ThirdPayOrderEntity::getCreateTime) - .or() - .lt(ThirdPayOrderEntity::getCreateTime, cutoff)) - .set(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_CLOSED.getType()))); + TenantBroker.runAs(order.getTenantId(), tenantId -> { + ThirdPayOrderEntity latest = thirdPayOrderService.getById(order.getId()); + if (latest != null && ObjectUtil.equals( + latest.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) { + thirdAppPlusService.queryPayResult(latest); + } + }); } catch (Exception exception) { log.warn("关闭历史订单码失败,orderId:{},tenantId:{},platform:{},exceptionType:{}", order.getId(), order.getTenantId(), order.getPayPlatformType(), diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdMchFzRelationMapper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdMchFzRelationMapper.java new file mode 100644 index 00000000..5e99d47b --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdMchFzRelationMapper.java @@ -0,0 +1,12 @@ +package com.bsd.pjcloud.thirdparty.mapper.pay; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 商户分账关系数据访问,关系领取与结果推进使用 Service 层的状态条件更新。 + */ +@Mapper +public interface ThirdMchFzRelationMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskAttemptMapper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskAttemptMapper.java new file mode 100644 index 00000000..0b3456e2 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskAttemptMapper.java @@ -0,0 +1,12 @@ +package com.bsd.pjcloud.thirdparty.mapper.pay; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPaymentTaskAttemptEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 支付任务尝试日志数据访问。 + */ +@Mapper +public interface ThirdPaymentTaskAttemptMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapper.java new file mode 100644 index 00000000..9d2e329e --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapper.java @@ -0,0 +1,168 @@ +package com.bsd.pjcloud.thirdparty.mapper.pay; + +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPaymentTaskEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 支付任务数据访问;跨租户方法仅供受保护的 Quartz 分发入口使用。 + */ +@Mapper +public interface ThirdPaymentTaskMapper extends PjcloudBaseMapper { + /** + * 从业务表读取尚未物化的任务候选,按业务更新时间限制返回数量。 + * @param taskType 任务类型编码 + * @param now 当前数据库扫描基准时间 + * @param limit 最大候选数量 + * @return 携带稳定任务键和关联主键的候选任务 + */ + @InterceptorIgnore(tenantLine = "true") + List selectMaterializationCandidates(@Param("taskType") String taskType, + @Param("now") LocalDateTime now, + @Param("limit") int limit); + + /** + * 使用唯一任务键忽略重复插入,解决回调、同步响应和 Quartz 并发物化。 + * @param task 完整任务事实 + * @return 新增一行时为1,唯一键已存在时为0 + */ + @InterceptorIgnore(tenantLine = "true") + int insertIgnore(ThirdPaymentTaskEntity task); + + /** + * 恢复当前类型的过期执行租约。 + * @param taskType 任务类型 + * @param now 当前时间 + * @return 恢复数量 + */ + @InterceptorIgnore(tenantLine = "true") + int recoverExpiredLeases(@Param("taskType") String taskType, @Param("now") LocalDateTime now); + + /** + * 查询本轮到期任务,真正所有权仍由后续 CAS 确认。 + * @param taskType 任务类型 + * @param now 当前时间 + * @param limit 最大返回数量 + * @return 按优先级和主键稳定排序的任务 + */ + @InterceptorIgnore(tenantLine = "true") + List selectDueTasks(@Param("taskType") String taskType, + @Param("now") LocalDateTime now, + @Param("limit") int limit); + + /** + * 以任务主键、原状态和到期条件原子取得租约。 + * @param id 任务主键 + * @param now 当前时间 + * @param owner Worker实例 + * @param token 随机租约令牌 + * @param expireTime 租约到期时间 + * @return 领取成功为1,否则为0 + */ + @InterceptorIgnore(tenantLine = "true") + int claim(@Param("id") Long id, @Param("now") LocalDateTime now, @Param("owner") String owner, + @Param("token") String token, @Param("expireTime") LocalDateTime expireTime); + + /** + * 任务真正离开本地有界队列时验证租约所有权并延长执行窗口,过期后已被其他实例恢复的旧队列项不会命中。 + * + * @param id 任务主键 + * @param token 当前队列项持有的租约令牌 + * @param owner 当前 thirdparty Worker 实例标识 + * @param expireTime 从实际开始执行时重新计算的租约到期时间 + * @param now 实际开始执行时间 + * @return 当前租约仍有效且续租成功时为1,否则为0 + */ + @InterceptorIgnore(tenantLine = "true") + int renewLease(@Param("id") Long id, @Param("token") String token, @Param("owner") String owner, + @Param("expireTime") LocalDateTime expireTime, @Param("now") LocalDateTime now); + + /** + * 提交队列失败时按租约释放任务。 + * @param id 任务主键 + * @param token 当前租约令牌 + * @param nextRunTime 下一次重试时间 + * @param message 队列拒绝摘要 + * @return 释放成功为1,否则为0 + */ + @InterceptorIgnore(tenantLine = "true") + int release(@Param("id") Long id, @Param("token") String token, + @Param("nextRunTime") LocalDateTime nextRunTime, @Param("message") String message); + + /** + * 按租约写入成功终态,过期 Worker 的更新不会命中。 + * @param id 任务主键 + * @param token 当前租约令牌 + * @param externalStatus 最近业务或渠道状态 + * @param now 完成时间 + * @return 更新数量 + */ + @InterceptorIgnore(tenantLine = "true") + int complete(@Param("id") Long id, @Param("token") String token, + @Param("externalStatus") String externalStatus, @Param("now") LocalDateTime now); + + /** + * 同步渠道响应按租户和任务键完成尚未领取的创建任务,绝不覆盖其他 Worker 已取得的租约。 + * @param tenantId 任务所属租户 + * @param taskKey 稳定任务键 + * @param status 目标成功状态 + * @param externalStatus 同步创建结果摘要 + * @param now 完成时间 + * @return 更新数量;任务已被领取或已终结时为0 + */ + @InterceptorIgnore(tenantLine = "true") + int completeByTaskKey(@Param("tenantId") Long tenantId, @Param("taskKey") String taskKey, + @Param("status") String status, @Param("externalStatus") String externalStatus, + @Param("now") LocalDateTime now); + + /** + * 按租约把未收敛任务写回退避队列或告警队列。 + * @param id 任务主键 + * @param token 当前租约令牌 + * @param status RETRY或ALERT + * @param nextRunTime 下一次执行时间 + * @param externalStatus 外部状态 + * @param errorCode 错误码 + * @param errorMessage 错误摘要 + * @return 更新数量 + */ + @InterceptorIgnore(tenantLine = "true") + int retry(@Param("id") Long id, @Param("token") String token, @Param("status") String status, + @Param("nextRunTime") LocalDateTime nextRunTime, @Param("externalStatus") String externalStatus, + @Param("errorCode") String errorCode, @Param("errorMessage") String errorMessage); + + /** + * 按租约写入确定性失败终态。 + * @param id 任务主键 + * @param token 当前租约令牌 + * @param errorCode 错误码 + * @param errorMessage 错误摘要 + * @param now 失败时间 + * @return 更新数量 + */ + @InterceptorIgnore(tenantLine = "true") + int fail(@Param("id") Long id, @Param("token") String token, @Param("errorCode") String errorCode, + @Param("errorMessage") String errorMessage, @Param("now") LocalDateTime now); + + /** + * 统计当前类型已到期积压。 + * @param taskType 任务类型 + * @param now 当前时间 + * @return READY、RETRY和ALERT到期数量 + */ + @InterceptorIgnore(tenantLine = "true") + long countBacklog(@Param("taskType") String taskType, @Param("now") LocalDateTime now); + + /** + * 人工重试终态或告警任务,保留原业务号和任务键。 + * @param id 任务主键 + * @param nextRunTime 立即可执行时间 + * @return 状态允许重试且更新成功为1 + */ + int manualRetry(@Param("id") Long id, @Param("nextRunTime") LocalDateTime nextRunTime); +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusAliPayService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusAliPayService.java index 0cc41446..93cf827e 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusAliPayService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusAliPayService.java @@ -3,9 +3,11 @@ package com.bsd.pjcloud.thirdparty.service; import com.alipay.api.response.AlipayTradeQueryResponse; import com.alipay.api.response.AlipayTradeCancelResponse; +import com.alipay.api.response.AlipayTradeFastpayRefundQueryResponse; import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto; import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdMiniWechatRefundDto; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity; 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.vo.mch.MchPaySettingVo; @@ -52,6 +54,60 @@ public interface ThirdAppPlusAliPayService { */ boolean refund(ThirdPayOrderEntity thirdPayOrder, ThirdMiniWechatRefundDto dto); + /** + * 按支付订单保存的账号快照查询支付宝退款及退分账结果。 + * + * @param payOrder 已进入退款中的支付订单,包含稳定退款号、请求参数和账号快照 + * @return 支付宝退款查询响应;网络或 SDK 异常时返回 {@code null} + */ + AlipayTradeFastpayRefundQueryResponse queryRefund(ThirdPayOrderEntity payOrder); + + /** + * 在支付成功达到直付通最早结算时间后,确认结算并执行平台抽佣;请求具有稳定幂等号。 + * + * @param thirdPayOrder 已确认支付成功的支付订单 + */ + void processDirectPayLifecycle(ThirdPayOrderEntity thirdPayOrder); + + /** + * 只执行支付宝直付通确认结算阶段,任务成功不代表平台抽佣成功。 + * @param fzOrder 分账订单 + * @param payOrder 已支付成功的支付订单 + * @return SUCCESS、PROCESSING 或 FAILED 阶段状态 + */ + String confirmDirectPaySettlementStage(ThirdFzOrderEntity fzOrder, ThirdPayOrderEntity payOrder); + + /** + * 只提交支付宝直付通平台抽佣,成功受理后返回 SUBMITTED,禁止提前写入最终分账成功。 + * @param fzOrder 分账订单 + * @param payOrder 已支付成功的支付订单 + * @return SUBMITTED、PROCESSING 或 FAILED 阶段状态 + */ + String submitDirectPayRoyaltyStage(ThirdFzOrderEntity fzOrder, ThirdPayOrderEntity payOrder); + + /** + * 查询支付宝平台抽佣最终状态;只有支付宝返回最终成功才推进 third_fz_order。 + * @param fzOrder 分账订单 + * @param payOrder 已支付成功的支付订单 + * @return SUCCESS、PROCESSING 或 FAILED 阶段状态 + */ + String queryDirectPayRoyaltyStage(ThirdFzOrderEntity fzOrder, ThirdPayOrderEntity payOrder); + + /** + * 只预留支付宝直付通分账记录,不发起任何支付宝结算或抽佣请求。 + * @param payOrder 已确认支付成功的支付宝订单 + * @return 已存在或新建的分账订单 + */ + ThirdFzOrderEntity ensureDirectPayFzOrder(ThirdPayOrderEntity payOrder); + + /** + * 查询或补偿支付宝直付通分账及分账退款状态。 + * + * @param fzOrder 已持久化的分账订单 + * @param payOrder 对应支付订单 + */ + void pollDirectPayLifecycle(ThirdFzOrderEntity fzOrder, ThirdPayOrderEntity payOrder); + /** * 支付回调 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusService.java index f1288ec6..4f68c2dd 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusService.java @@ -91,6 +91,13 @@ public interface ThirdAppPlusService { void queryPayResult(ThirdPayOrderEntity dto); + /** + * 查询普通退款平台状态并以退款中的原状态条件推进本地订单,同时补偿业务退款回调。 + * + * @param dto 待查询的退款订单 + */ + void queryRefundResult(ThirdPayOrderEntity dto); + /** * 小程序退款回调 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusWechatFzService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusWechatFzService.java index 3735f15e..4fb93a73 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusWechatFzService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusWechatFzService.java @@ -34,6 +34,20 @@ public interface ThirdAppPlusWechatFzService { */ ReturnOrdersEntity getFzRefundOrder(ThirdFzOrderEntity fzOrder); + /** + * 查询微信分账并仅在 FINISHED 时推进最终成功状态。 + * + * @param fzOrder 待确认的微信分账订单 + */ + void pollFzOrder(ThirdFzOrderEntity fzOrder); + + /** + * 查询微信分账退款并映射 SUCCESS、FAILED、PROCESSING 状态。 + * + * @param fzOrder 退款中的微信分账订单 + */ + void pollFzRefundOrder(ThirdFzOrderEntity fzOrder); + /** * 添加分账方 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusWechatService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusWechatService.java index 4366dcae..b744861d 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusWechatService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAppPlusWechatService.java @@ -12,10 +12,17 @@ import org.springframework.web.bind.annotation.RequestHeader; import java.util.Map; +/** + * 微信服务商 App 支付能力,封装下单、查单、关单、退款及回调验真。 + */ public interface ThirdAppPlusWechatService { /** - * APP支付 + * 创建微信服务商 App 支付。 + * + * @param dto 已由支付编排校验的业务订单、用户和金额 + * @param setting 当前商户启用的微信支付配置 + * @return 微信 App 调起参数;渠道调用异常时为空 */ Map appPay(ThirdAppPlusPayDto dto, MchPaySettingVo setting); @@ -30,10 +37,23 @@ public interface ThirdAppPlusWechatService { WxMaPhoneNumberInfo getPhone(String code); /** - * 查询订单 + * 按订单创建时保存的商户配置快照查询微信 App 支付状态。 + * + * @param payNo 第三方支付服务生成的商户订单号 + * @param settingJson 创建订单时保存的子商户支付配置快照 + * @return 微信订单状态;网络、配置或 SDK 异常时为空 */ Transaction query(String payNo, String settingJson); + /** + * 关闭仍未支付的微信服务商 App 订单。 + * + * @param payNo 第三方支付服务生成的商户订单号 + * @param settingJson 创建订单时保存的子商户支付配置快照 + * @return 微信确认关单成功时返回 {@code true};异常时返回 {@code false} 供任务重试 + */ + boolean close(String payNo, String settingJson); + /** * 撤销订单 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java new file mode 100644 index 00000000..7de72303 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java @@ -0,0 +1,34 @@ +package com.bsd.pjcloud.thirdparty.service; + +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; + +/** + * 商户支付分账关系服务,负责把 {@code mch_manage} 的支付配置映射为渠道关系事实。 + */ +public interface ThirdMchFzRelationService { + + /** + * 根据商户当前启用支付配置物化默认平台分佣关系;已有相同身份关系只做幂等复用。 + * + * @param merchant 商户及其启用支付配置,来源于 wallet 服务的内部快照 + */ + void syncDefaultRelations(MchManageVo merchant); + + /** + * 获取支付创建时必须使用的已绑定关系;有抽佣配置但关系未绑定时直接拒绝支付。 + * + * @param setting 商户支付配置 + * @return 已绑定关系;未启用平台抽佣时返回 {@code null} + */ + ThirdMchFzRelationEntity requireBound(MchPaySettingVo setting); + + /** + * 执行一条关系绑定任务;重复执行必须使用关系事实中保存的稳定请求号和参数。 + * + * @param relationId 关系主键 + * @return 渠道明确确认绑定时返回 {@code true},未知或失败返回 {@code false} + */ + boolean bind(Long relationId); +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayPollingCoordinator.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayPollingCoordinator.java index 9de0f7f0..ae468db2 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayPollingCoordinator.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayPollingCoordinator.java @@ -5,10 +5,10 @@ import org.springframework.stereotype.Component; import java.util.concurrent.TimeUnit; /** - * 第三方订单码服务端轮询协调器。 + * 旧批处理入口的进程内门控器。 * - *

支付平台回调仍是结果确认主路径;本协调器只控制数据库补偿扫描频率。有待处理记录时维持短间隔, - * 连续空扫描时指数退避,订单创建成功后由当前服务实例主动唤醒,从而避免全天执行无效 SQL。

+ *

本类不创建线程、不注册定时器,也不会主动扫描数据库。它只供保留的兼容诊断方法控制同进程重入; + * 生产支付任务的唯一周期来源是 Quartz,真实恢复状态由 {@code third_payment_task} 保存。

*/ @Component public class ThirdPayPollingCoordinator { @@ -24,9 +24,9 @@ public class ThirdPayPollingCoordinator { public static final long COMPENSATION_TRIGGER_INTERVAL_MILLIS = 30000L; /** - * 订单码允许服务端自动查单、关单或恢复会话状态的最长时间,单位分钟。 + * 旧批处理区分活动记录和历史记录的时间窗口,单位分钟。 * - *

超过该硬截止后只允许本地标记关闭,客户端查询、定时任务和创建重试均不得再次访问支付平台。

+ *

该窗口只影响扫描分组,不能作为本地关闭渠道订单的依据;存在渠道单号时仍须查单或关单确认。

*/ public static final long MAX_AUTO_PROCESSING_MINUTES = 60L; @@ -60,6 +60,21 @@ public class ThirdPayPollingCoordinator { */ private final AdaptivePollingGate aggregateCompensationGate; + /** + * App、小程序支付状态补偿扫描门。 + */ + private final AdaptivePollingGate appPaymentGate; + + /** + * 普通退款状态补偿扫描门。 + */ + private final AdaptivePollingGate refundGate; + + /** + * 微信及支付宝分账状态补偿扫描门。 + */ + private final AdaptivePollingGate fzGate; + /** * 创建轮询协调器并让三个扫描门在服务启动后各执行一次恢复扫描。 */ @@ -68,6 +83,9 @@ public class ThirdPayPollingCoordinator { aggregatePendingGate = new AdaptivePollingGate(ACTIVE_TRIGGER_INTERVAL_MILLIS, ACTIVE_MAX_IDLE_INTERVAL_MILLIS); aggregateCompensationGate = new AdaptivePollingGate( COMPENSATION_TRIGGER_INTERVAL_MILLIS, COMPENSATION_MAX_IDLE_INTERVAL_MILLIS); + appPaymentGate = new AdaptivePollingGate(ACTIVE_TRIGGER_INTERVAL_MILLIS, ACTIVE_MAX_IDLE_INTERVAL_MILLIS); + refundGate = new AdaptivePollingGate(COMPENSATION_TRIGGER_INTERVAL_MILLIS, COMPENSATION_MAX_IDLE_INTERVAL_MILLIS); + fzGate = new AdaptivePollingGate(COMPENSATION_TRIGGER_INTERVAL_MILLIS, COMPENSATION_MAX_IDLE_INTERVAL_MILLIS); } /** @@ -141,6 +159,69 @@ public class ThirdPayPollingCoordinator { aggregateCompensationGate.finish(hasExpiredSessions); } + /** + * 尝试取得 App 支付订单轮询资格。 + * + * @return 当前允许扫描时返回 {@code true} + */ + public boolean tryStartAppPaymentPolling() { + return appPaymentGate.tryStart(); + } + + /** + * 完成 App 支付订单轮询。 + * + * @param hasPendingOrders 是否存在待处理支付订单 + */ + public void finishAppPaymentPolling(boolean hasPendingOrders) { + appPaymentGate.finish(hasPendingOrders); + } + + /** + * 尝试取得普通退款轮询资格。 + * + * @return 当前允许扫描时返回 {@code true} + */ + public boolean tryStartRefundPolling() { + return refundGate.tryStart(); + } + + /** + * 完成普通退款轮询。 + * + * @param hasPendingRefunds 是否存在待处理退款 + */ + public void finishRefundPolling(boolean hasPendingRefunds) { + refundGate.finish(hasPendingRefunds); + } + + /** + * 尝试取得分账及分账退款轮询资格。 + * + * @return 当前允许扫描时返回 {@code true} + */ + public boolean tryStartFzPolling() { + return fzGate.tryStart(); + } + + /** + * 完成分账及分账退款轮询。 + * + * @param hasPendingFz 是否存在待处理分账记录 + */ + public void finishFzPolling(boolean hasPendingFz) { + fzGate.finish(hasPendingFz); + } + + /** + * 新支付、退款或分账记录创建后唤醒补偿扫描门。 + */ + public void wakePaymentCompensationPolling() { + appPaymentGate.wakeUp(); + refundGate.wakeUp(); + fzGate.wakeUp(); + } + /** * 单类轮询任务的进程内自适应扫描门。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentOrderReservationService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentOrderReservationService.java new file mode 100644 index 00000000..49cb9b7b --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentOrderReservationService.java @@ -0,0 +1,73 @@ +package com.bsd.pjcloud.thirdparty.service; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskStatusEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPaymentTaskEntity; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskMapper; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * 支付订单预留事务边界,确保调用外部渠道前订单事实和首个创建任务已经一并提交。 + */ +@Service +@RequiredArgsConstructor +public class ThirdPaymentOrderReservationService { + + /** 支付订单服务,负责持久化订单快照。 */ + private final ThirdPayOrderService payOrderService; + + /** 支付任务写入器,使用租户内稳定任务键避免重复创建。 */ + private final ThirdPaymentTaskEnqueuer taskEnqueuer; + + /** 支付任务 Mapper,用于同步响应后关闭创建阶段任务。 */ + private final ThirdPaymentTaskMapper taskMapper; + + /** + * 在独立事务中预留支付订单和 PAY_CREATE 任务;事务必须在外部网络调用开始前提交。 + * @param payOrder 已包含支付请求、商户配置快照和稳定支付单号的未支付订单 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) + public void reserveAppPay(ThirdPayOrderEntity payOrder) { + if (payOrder == null || StrUtil.isBlank(payOrder.getPayNo())) { + throw new IllegalArgumentException("预留支付订单和支付单号不能为空"); + } + if (payOrder.getTenantId() == null) { + payOrder.setTenantId(TenantContextHolder.getTenantId()); + } + if (!payOrderService.save(payOrder)) { + throw new IllegalStateException("预留支付订单失败"); + } + taskEnqueuer.enqueue(payOrder.getTenantId(), ThirdPaymentTaskTypeEnum.PAY_CREATE, + "PAY_CREATE:" + payOrder.getPayNo(), payOrder.getId(), payOrder.getPayNo(), null, null, null); + } + + /** + * 保存渠道同步创建响应,完成 PAY_CREATE 并创建后续 PAY_QUERY;三项更新保持同一事务。 + * @param payOrder 已预留且携带渠道同步响应的支付订单 + */ + @Transactional(rollbackFor = Exception.class) + public void finishAppPayCreate(ThirdPayOrderEntity payOrder) { + if (payOrder == null || payOrder.getId() == null || StrUtil.isBlank(payOrder.getPayNo()) + || StrUtil.isBlank(payOrder.getPayRes())) { + return; + } + ThirdPayOrderEntity update = new ThirdPayOrderEntity(); + update.setPayRes(payOrder.getPayRes()); + payOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, payOrder.getId())); + Long tenantId = payOrder.getTenantId() == null ? TenantContextHolder.getTenantId() : payOrder.getTenantId(); + LocalDateTime now = LocalDateTime.now(); + taskMapper.completeByTaskKey(tenantId, "PAY_CREATE:" + payOrder.getPayNo(), + ThirdPaymentTaskStatusEnum.SUCCESS.getCode(), "CREATE_ACCEPTED", now); + taskEnqueuer.enqueuePayQuery(payOrder.getTenantId(), payOrder.getId(), payOrder.getPayNo()); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuer.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuer.java new file mode 100644 index 00000000..63ca7af9 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuer.java @@ -0,0 +1,223 @@ +package com.bsd.pjcloud.thirdparty.service; + +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPaymentTaskEntity; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskMapper; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +/** + * 实时支付路径的轻量任务写入器,只依赖任务 Mapper,避免与 Worker 业务编排形成循环依赖。 + */ +@Service +@RequiredArgsConstructor +public class ThirdPaymentTaskEnqueuer { + /** 支付任务 Mapper。 */ + private final ThirdPaymentTaskMapper taskMapper; + + /** + * 为支付订单写入查单任务;唯一键冲突时保持既有任务,不重新生成支付号。 + * @param tenantId 租户ID + * @param payOrderId 支付订单主键 + * @param payNo 稳定支付单号 + */ + public void enqueuePayQuery(Long tenantId, Long payOrderId, String payNo) { + enqueue(tenantId, ThirdPaymentTaskTypeEnum.PAY_QUERY, "PAY_QUERY:" + payNo, payOrderId, payNo, null, null, null); + } + + /** + * 为退款订单写入退款查单任务;退款号是部分退款场景的幂等边界。 + * @param tenantId 租户ID + * @param payOrderId 支付订单主键 + * @param payNo 原支付单号 + * @param refundNo 稳定退款单号 + */ + public void enqueueRefundQuery(Long tenantId, Long payOrderId, String payNo, String refundNo) { + enqueue(tenantId, ThirdPaymentTaskTypeEnum.REFUND_QUERY, "REFUND_QUERY:" + payNo + ":" + refundNo, + payOrderId, payNo, refundNo, null, null); + } + + /** + * 为支付或退款后的业务后置回调写入补偿任务;任务键包含本地终态,避免退款单切换后复用旧回调任务。 + * @param tenantId 租户ID,可为空并从租户上下文读取 + * @param payOrderId 支付订单主键 + * @param payNo 稳定支付单号 + * @param status 需要补偿的本地终态编码 + */ + public void enqueueBusinessCallback(Long tenantId, Long payOrderId, String payNo, String status) { + enqueue(tenantId, ThirdPaymentTaskTypeEnum.BIZ_CALLBACK, "BIZ_CALLBACK:" + payNo + ":" + status, + payOrderId, payNo, null, null, null); + } + + /** + * 为已创建的微信分账记录排入稳定查单任务。 + * + * @param tenantId 分账订单所属租户ID;为空时读取当前租户上下文 + * @param payOrderId 原支付订单主键 + * @param fzOrderId 分账订单主键 + * @param payNo 原支付订单稳定支付单号 + * @param fzNo 微信分账稳定业务单号 + */ + public void enqueueFzQuery(Long tenantId, Long payOrderId, Long fzOrderId, String payNo, String fzNo) { + enqueueAt(tenantId, ThirdPaymentTaskTypeEnum.FZ_QUERY, "FZ_QUERY:" + fzNo, + payOrderId, payNo, null, fzOrderId, fzNo, LocalDateTime.now(), 100); + } + + /** + * 为支付宝直付通排入确认结算任务,调用方必须传入不早于支付成功约三十秒的执行时间。 + * + * @param tenantId 支付订单所属租户ID;为空时读取当前租户上下文 + * @param payOrderId 原支付订单主键 + * @param fzOrderId 直付通分账阶段记录主键 + * @param payNo 原支付订单稳定支付单号 + * @param fzNo 直付通稳定分账业务号 + * @param nextRunTime 最早确认结算时间;为空时立即可领取,仅供显式人工恢复使用 + */ + public void enqueueFzSettleConfirm(Long tenantId, Long payOrderId, Long fzOrderId, String payNo, String fzNo, + LocalDateTime nextRunTime) { + enqueueAt(tenantId, ThirdPaymentTaskTypeEnum.FZ_SETTLE_CONFIRM, "FZ_SETTLE_CONFIRM:" + payNo, + payOrderId, payNo, null, fzOrderId, fzNo, nextRunTime, 100); + } + + /** + * 为确认结算成功的支付宝订单排入平台抽佣提交任务。 + * + * @param tenantId 支付订单所属租户ID;为空时读取当前租户上下文 + * @param payOrderId 原支付订单主键 + * @param fzOrderId 直付通分账阶段记录主键 + * @param payNo 原支付订单稳定支付单号 + * @param fzNo 直付通稳定分账业务号 + */ + public void enqueueFzRoyalty(Long tenantId, Long payOrderId, Long fzOrderId, String payNo, String fzNo) { + enqueueAt(tenantId, ThirdPaymentTaskTypeEnum.FZ_ROYALTY, "FZ_ROYALTY:" + payNo, + payOrderId, payNo, null, fzOrderId, fzNo, LocalDateTime.now(), 100); + } + + /** + * 为支付宝平台抽佣受理结果排入稳定查询任务。 + * + * @param tenantId 支付订单所属租户ID;为空时读取当前租户上下文 + * @param payOrderId 原支付订单主键 + * @param fzOrderId 直付通分账阶段记录主键 + * @param payNo 原支付订单稳定支付单号 + * @param fzNo 直付通稳定分账业务号 + */ + public void enqueueFzRoyaltyQuery(Long tenantId, Long payOrderId, Long fzOrderId, String payNo, String fzNo) { + enqueueAt(tenantId, ThirdPaymentTaskTypeEnum.FZ_ROYALTY_QUERY, "FZ_ROYALTY_QUERY:" + payNo, + payOrderId, payNo, null, fzOrderId, fzNo, LocalDateTime.now(), 100); + } + + /** + * 为已发起的微信或支付宝分账退款排入稳定查单任务。 + * + * @param tenantId 分账退款所属租户ID;为空时读取当前租户上下文 + * @param payOrderId 原支付订单主键 + * @param fzOrderId 原分账订单主键 + * @param payNo 原支付订单稳定支付单号 + * @param fzNo 原分账稳定业务单号 + * @param fzRefundNo 分账退款稳定业务号,用于区分同一分账单的退款尝试 + */ + public void enqueueFzRefundQuery(Long tenantId, Long payOrderId, Long fzOrderId, String payNo, String fzNo, + String fzRefundNo) { + enqueueAt(tenantId, ThirdPaymentTaskTypeEnum.FZ_REFUND_QUERY, + "FZ_REFUND_QUERY:" + fzNo + ":" + fzRefundNo, + payOrderId, payNo, null, fzOrderId, fzNo, LocalDateTime.now(), 110); + } + + /** + * 为历史聚合会话补建唯一的状态收敛任务,避免 {@code AGGREGATE_CLOSE} 与活动会话任务并发查同一渠道单。 + * + * @param tenantId 会话所属租户ID,不允许为空 + * @param aggregateSessionId 聚合会话主键 + * @param aggregateNo 聚合会话稳定业务号 + * @param payNo 已绑定的渠道支付单号 + * @param nextRunTime 最早执行时间;为空时立即可执行 + */ + public void enqueueAggregateSession(Long tenantId, Long aggregateSessionId, String aggregateNo, String payNo, + LocalDateTime nextRunTime) { + if (tenantId == null || aggregateSessionId == null || aggregateNo == null || aggregateNo.isBlank()) { + throw new IllegalArgumentException("聚合支付任务租户、会话主键和会话号不能为空"); + } + LocalDateTime now = LocalDateTime.now(); + ThirdPaymentTaskEntity task = new ThirdPaymentTaskEntity(); + task.setId(IdWorker.getId()); + task.setTenantId(tenantId); + task.setTaskType(ThirdPaymentTaskTypeEnum.AGGREGATE_SESSION.getCode()); + task.setTaskKey("AGGREGATE_SESSION:" + aggregateNo); + task.setAggregateSessionId(aggregateSessionId); + task.setAggregateNo(aggregateNo); + task.setPayNo(payNo); + task.setStatus("READY"); + task.setNextRunTime(nextRunTime == null ? now : nextRunTime); + task.setPriority(105); + task.setAttemptCount(0); + task.setCreateTime(now); + task.setUpdateTime(now); + task.setDelFlag("0"); + taskMapper.insertIgnore(task); + } + + /** + * 写入一个稳定的任务事实。 + * @param tenantId 租户ID + * @param type 任务类型 + * @param taskKey 租户内幂等键 + * @param payOrderId 支付订单主键 + * @param payNo 支付单号 + * @param refundNo 退款单号 + * @param fzOrderId 分账订单主键 + * @param fzNo 分账单号 + */ + public void enqueue(Long tenantId, ThirdPaymentTaskTypeEnum type, String taskKey, Long payOrderId, + String payNo, String refundNo, Long fzOrderId, String fzNo) { + enqueueAt(tenantId, type, taskKey, payOrderId, payNo, refundNo, fzOrderId, fzNo, + LocalDateTime.now(), 100); + } + + /** + * 写入指定执行时间和优先级的任务,外部业务号和任务键保持不变。 + * + * @param tenantId 租户ID;为空时读取当前租户上下文 + * @param type 任务类型,持久化编码由枚举提供 + * @param taskKey 租户内稳定幂等键 + * @param payOrderId 支付订单主键,非支付订单任务允许为空 + * @param payNo 稳定支付单号,未关联支付订单时允许为空 + * @param refundNo 稳定退款单号,非退款任务允许为空 + * @param fzOrderId 分账订单主键,非分账任务允许为空 + * @param fzNo 稳定分账单号,非分账任务允许为空 + * @param nextRunTime 最早执行时间;为空时立即可执行 + * @param priority 调度优先级,数值越大越先领取 + */ + public void enqueueAt(Long tenantId, ThirdPaymentTaskTypeEnum type, String taskKey, Long payOrderId, + String payNo, String refundNo, Long fzOrderId, String fzNo, + LocalDateTime nextRunTime, int priority) { + tenantId = tenantId == null ? TenantContextHolder.getTenantId() : tenantId; + if (tenantId == null || type == null || taskKey == null || taskKey.isBlank()) { + throw new IllegalArgumentException("支付任务租户、类型和任务键不能为空"); + } + LocalDateTime now = LocalDateTime.now(); + ThirdPaymentTaskEntity task = new ThirdPaymentTaskEntity(); + // 任务表主键没有数据库自增,实时回调路径必须和物化路径一样显式生成分布式 ID。 + task.setId(IdWorker.getId()); + task.setTenantId(tenantId); + task.setTaskType(type.getCode()); + task.setTaskKey(taskKey); + task.setPayOrderId(payOrderId); + task.setPayNo(payNo); + task.setRefundNo(refundNo); + task.setFzOrderId(fzOrderId); + task.setFzNo(fzNo); + 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); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskService.java new file mode 100644 index 00000000..e0fefd81 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskService.java @@ -0,0 +1,32 @@ +package com.bsd.pjcloud.thirdparty.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPaymentTaskDispatchDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPaymentTaskEntity; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPaymentTaskDispatchVo; + +/** + * 支付任务物化、分发、租约领取和人工重试服务。 + */ +public interface ThirdPaymentTaskService extends IService { + /** + * 接收 Quartz 的一次固定动作触发。 + * @param dto 分发动作和批量限制 + * @return 分发统计 + */ + ThirdPaymentTaskDispatchVo dispatch(ThirdPaymentTaskDispatchDto dto); + + /** + * 创建稳定任务;唯一键冲突时保持已有任务不变。 + * @param task 任务事实 + * @return 插入成功或任务已存在时均返回 true + */ + boolean enqueue(ThirdPaymentTaskEntity task); + + /** + * 使用原任务键将终态任务放回退避队列。 + * @param taskId 任务主键 + * @return 更新成功时为 true + */ + boolean manualRetry(Long taskId); +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java index d1c8d862..49e106fd 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java @@ -156,7 +156,10 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.getAliPayConfig(); request.setNotifyUrl(aliPayConfig.getBaseCallbackUrl() + aliPayConfig.getPayCallbackUrl()); request.setBizModel(model); - request.putOtherTextParam(APP_AUTH_TOKEN_PARAM, aliPaySetting.getAppAuthToken()); + // app_auth_token 仅在商户配置了代调用授权时发送;为空时必须让支付宝按平台默认授权处理。 + if (StrUtil.isNotBlank(aliPaySetting.getAppAuthToken())) { + request.putOtherTextParam(APP_AUTH_TOKEN_PARAM, aliPaySetting.getAppAuthToken()); + } payOrder.setPayRep(buildRequestSnapshot(context).toString()); AlipayTradeCreateResponse response; @@ -296,19 +299,16 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ } /** - * 校验商户支付宝授权配置并解析 app_auth_token。 + * 校验商户支付宝配置并解析可选 app_auth_token。 * * @param paySetting 当前商户启用的支付宝支付配置 - * @return 包含非空商户授权令牌的配置 + * @return 支付宝配置;授权令牌为空时仍允许直付通请求继续,由支付宝返回明确权限结果 */ private MchPaySettingEntity.AliPaySetting parseAliPaySetting(MchPaySettingVo paySetting) { if (paySetting == null || StrUtil.isBlank(paySetting.getSettingJson())) { throw new RuntimeException("商户支付宝支付配置不完整"); } MchPaySettingEntity.AliPaySetting setting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - if (setting == null || StrUtil.isBlank(setting.getAppAuthToken())) { - throw new RuntimeException("商户支付宝授权Token未配置"); - } return setting; } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java index bd1cacff..e801740b 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java @@ -249,9 +249,10 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay } /** - * 查询当前租户聚合会话,并在自动处理期限内同步第三方子订单终态。 + * 查询当前租户聚合会话,并同步仍有渠道子订单的真实终态。 * - *

创建已满一小时的会话只执行本地关闭,不因客户端持续查询再次访问支付平台或恢复历史会话。

+ *

历史年龄只影响任务扫描优先级,不能覆盖渠道事实;只要会话保存了 {@code payNo}, + * 就必须依据订单配置快照查单或关单。这样可以恢复延迟成功回调,避免本地超时先于支付平台事实关闭订单。

* * @param aggregateNo 聚合支付会话编号 * @return 最小聚合支付状态 @@ -411,11 +412,11 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay } /** - * 处理一条待确认聚合会话或处于有限补偿窗口内的过期会话。 + * 处理一条待确认聚合会话或处于补偿中的过期会话。 * - *

活动会话到期后即使第三方查询或关单异常,也会在本轮结束时推进为过期状态,退出五秒高频队列; - * 后续仅由服务端低频补偿任务在限定窗口内重试。创建已满一小时后只执行本地关闭,不再访问平台, - * 因此客户端查询和创建重试均不能形成无限历史恢复。

+ *

没有 {@code payNo} 的会话从未绑定渠道子订单,可以在本地过期;已经绑定渠道子订单的会话无论 + * 创建时间多早,都必须查单或关单确认。网络异常直接向任务 Worker 抛出,任务表会按原任务键退避重试, + * 不能在 finally 中把未知结果写成过期或关闭。

* * @param sessionId 聚合支付会话主键 */ @@ -427,10 +428,6 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay if (session == null || (!isActive(session) && !expiredCompensation)) { return; } - if (isBeyondAutoProcessingWindow(session)) { - closeHistoricalSessionLocally(session); - return; - } if (ObjectUtil.equals(session.getStatus(), ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType())) { if (isExpired(session)) { aggregatePaySessionService.markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.EXPIRED); @@ -445,33 +442,26 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay } ThirdPayOrderEntity payOrder = thirdPayOrderService.getByPayNo(session.getPayNo()); if (payOrder == null) { - if (!expiredCompensation && isExpired(session)) { - aggregatePaySessionService.markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.EXPIRED); - } + // payNo 已经预分配,子订单缺失可能意味着外部创建请求已发出但本地保存尚未完成;未知事实不能本地关闭。 return; } aggregatePaySessionService.syncFromPayOrder(payOrder); if (ObjectUtil.notEqual(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) { return; } - boolean reachedExpireTime = isExpired(session); - try { - if (ObjectUtil.equals(session.getSelectedPlatform(), AppPlusPlatformTypeEnum.ALI_PAY.getType())) { - processAlipayOrder(session, payOrder); - } else if (ObjectUtil.equals(session.getSelectedPlatform(), AppPlusPlatformTypeEnum.WECHAT.getType())) { - processWechatOrder(session, payOrder); - } - } finally { - if (!expiredCompensation && reachedExpireTime) { - aggregatePaySessionService.markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.EXPIRED); - } + String platformType = StrUtil.blankToDefault(session.getSelectedPlatform(), payOrder.getPayPlatformType()); + if (ObjectUtil.equals(platformType, AppPlusPlatformTypeEnum.ALI_PAY.getType())) { + processAlipayOrder(session, payOrder); + } else if (ObjectUtil.equals(platformType, AppPlusPlatformTypeEnum.WECHAT.getType())) { + processWechatOrder(session, payOrder); } } /** * 识别共享回调中的聚合 JSAPI 子订单并在自动处理期限内同步聚合会话。 * - *

超过一小时到达的延迟回调仍保留子订单真实支付事实,但聚合会话只标记关闭,不恢复为成功。

+ *

已验签的延迟成功回调仍以渠道事实为准,即使会话已经进入 EXPIRED,也允许 CAS 恢复为支付成功; + * 只有没有绑定渠道子订单的历史会话才可以本地终止。

* * @param payOrder 已通过平台验签和关键字段校验的最新第三方支付订单 * @return 找到 pay_no 绑定聚合会话时返回 {@code true} @@ -485,10 +475,6 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay if (session == null) { return false; } - if (isBeyondAutoProcessingWindow(session)) { - closeHistoricalSessionLocally(session); - return true; - } aggregatePaySessionService.syncFromPayOrder(payOrder); return true; } @@ -682,19 +668,14 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay if (session == null || StrUtil.isBlank(session.getPayNo())) { return; } - if (isBeyondAutoProcessingWindow(session)) { - closeHistoricalSessionLocally(session); - return; - } aggregatePaySessionService.syncFromPayOrder(thirdPayOrderService.getByPayNo(session.getPayNo())); } /** * 新建支付订单前确认已过期会话绑定的第三方子订单不再处于可支付状态。 * - *

后台补偿超过十分钟后会停止,但创建新订单时仍会同步执行一次查单和关单;只有旧子订单已经成功、 - * 关闭、失败或不存在时才允许继续,避免旧单与新单同时可支付。创建已满一小时的会话只做本地关闭, - * 不再通过该入口访问平台或恢复会话状态。

+ *

后台任务和创建重试都必须确认已绑定渠道子订单的真实终态;网络或渠道处理中状态继续阻止新单, + * 只有成功、关闭、失败等明确终态才允许继续,避免旧单与新单同时可支付。

* * @param session 同业务最新聚合会话,允许为空或非过期状态 */ @@ -711,7 +692,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay ThirdAggregatePaySessionEntity latestSession = aggregatePaySessionService.getById(session.getId()); ThirdPayOrderEntity payOrder = StrUtil.isBlank(session.getPayNo()) ? null : thirdPayOrderService.getByPayNo(session.getPayNo()); - if (payOrder != null && !isBeyondAutoProcessingWindow(session)) { + if (payOrder != null) { aggregatePaySessionService.syncFromPayOrder(payOrder); } if ((latestSession != null && ObjectUtil.equals(latestSession.getStatus(), @@ -991,29 +972,6 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay return session == null || session.getCreateTime() == null || !session.getCreateTime().isAfter(cutoff); } - /** - * 在自动处理硬截止后仅将聚合会话及其未支付子订单标记为关闭。 - * - *

先关闭会话可阻止随后到达的子订单同步重新激活会话;子订单更新带未支付条件,不覆盖已经落库的 - * 真实成功状态。该方法不调用支付宝或微信接口。

- * - * @param session 已越过自动处理硬截止的活动或过期会话 - */ - private void closeHistoricalSessionLocally(ThirdAggregatePaySessionEntity session) { - aggregatePaySessionService.markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED); - if (StrUtil.isBlank(session.getPayNo())) { - return; - } - ThirdPayOrderEntity payOrder = thirdPayOrderService.getByPayNo(session.getPayNo()); - if (payOrder == null) { - return; - } - thirdPayOrderService.update(new LambdaUpdateWrapper() - .eq(ThirdPayOrderEntity::getId, payOrder.getId()) - .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType()) - .set(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_CLOSED.getType())); - } - /** * 阻止同业务存在未归属于聚合会话的旧原生订单码或既有成功订单。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java index d02795d6..3d4f2a02 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java @@ -16,28 +16,52 @@ import com.alipay.api.domain.AlipayTradeCancelModel; import com.alipay.api.domain.AlipayTradePrecreateModel; import com.alipay.api.domain.AlipayTradeQueryModel; import com.alipay.api.domain.AlipayTradeRefundModel; +import com.alipay.api.domain.AlipayTradeFastpayRefundQueryModel; +import com.alipay.api.domain.SubMerchant; +import com.alipay.api.domain.AlipayTradeSettleConfirmModel; +import com.alipay.api.domain.AlipayTradeOrderSettleModel; +import com.alipay.api.domain.AlipayTradeOrderSettleQueryModel; +import com.alipay.api.domain.OpenApiRoyaltyDetailInfoPojo; +import com.alipay.api.domain.RoyaltyDetail; +import com.alipay.api.domain.SettleDetailInfo; +import com.alipay.api.domain.SettleInfo; +import com.alipay.api.domain.SettleConfirmExtendParams; +import com.alipay.api.domain.SettleExtendParams; import com.alipay.api.internal.util.AlipaySignature; import com.alipay.api.request.AlipayTradeAppPayRequest; import com.alipay.api.request.AlipayTradeCancelRequest; import com.alipay.api.request.AlipayTradePrecreateRequest; import com.alipay.api.request.AlipayTradeQueryRequest; import com.alipay.api.request.AlipayTradeRefundRequest; +import com.alipay.api.request.AlipayTradeFastpayRefundQueryRequest; +import com.alipay.api.request.AlipayTradeSettleConfirmRequest; +import com.alipay.api.request.AlipayTradeOrderSettleRequest; +import com.alipay.api.request.AlipayTradeOrderSettleQueryRequest; import com.alipay.api.response.AlipayTradeAppPayResponse; import com.alipay.api.response.AlipayTradeCancelResponse; import com.alipay.api.response.AlipayTradePrecreateResponse; import com.alipay.api.response.AlipayTradeQueryResponse; import com.alipay.api.response.AlipayTradeRefundResponse; +import com.alipay.api.response.AlipayTradeFastpayRefundQueryResponse; +import com.alipay.api.response.AlipayTradeSettleConfirmResponse; +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.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.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity; import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService; import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService; 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.vo.appplus.pay.alipay.ThirdALiPayCallbackVo; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; @@ -45,6 +69,7 @@ import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; @@ -53,6 +78,7 @@ import java.math.RoundingMode; import java.time.LocalDateTime; import java.util.List; import java.util.Map; +import java.util.function.BiConsumer; /** * 支付宝小程序(app版本) @@ -83,6 +109,21 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService */ private static final String APP_AUTH_TOKEN_PARAM = "app_auth_token"; + /** + * 支付宝明确表示请求结果不确定、必须保持原参数重试的系统错误码。 + */ + private static final List RETRYABLE_ALIPAY_ERROR_CODES = List.of( + "ACQ.SYSTEM_ERROR", "ACQ.TRADE_SETTLE_ERROR", "ACQ.FREQUENCY_LIMITED"); + + /** + * 支付宝退款查询明确表示当前退款请求尚无记录的业务错误码。 + * + *

遇到这些错误必须使用已保存的相同 {@code out_request_no} 和请求参数幂等重提, + * 不能直接把普通退款或退分账推进为失败。

+ */ + private static final List ALIPAY_REFUND_NOT_FOUND_CODES = List.of( + "ACQ.REFUND_NOT_EXIST", "ACQ.TRADE_NOT_EXIST"); + /** * 当前应用配置对应的支付宝客户端;延迟初始化后由所有支付请求复用。 */ @@ -98,6 +139,12 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService */ private final ThirdPayOrderService thirdPayOrderService; + /** + * 支付宝直付通分账订单服务;使用现有 third_fz_order 表保存两阶段请求快照。 + */ + @Autowired(required = false) + private ThirdFzOrderService thirdFzOrderService; + /** * 聚合会话服务只用于共享回调确认 JSAPI 子订单具有严格的 pay_no 会话绑定。 * @@ -106,6 +153,14 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService @Autowired(required = false) private ThirdAggregatePaySessionService aggregatePaySessionService; + /** 实时写入支付查单和退款查单任务,不负责周期调度。 */ + @Autowired(required = false) + private ThirdPaymentTaskEnqueuer paymentTaskEnqueuer; + + /** 支付订单与首任务预留事务;可选注入仅用于兼容既有纯单元测试构造方式。 */ + @Autowired(required = false) + private ThirdPaymentOrderReservationService paymentOrderReservationService; + /** * 构建支付宝 SDK 客户端配置,不输出或复制任何密钥到日志。 * @@ -143,6 +198,67 @@ 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 商户支付宝配置 + */ + private void putAppAuthToken(BiConsumer textParamWriter, + MchPaySettingEntity.AliPaySetting aliPaySetting) { + if (aliPaySetting != null && StrUtil.isNotBlank(aliPaySetting.getAppAuthToken())) { + // SDK 的 getTextParams() 每次返回请求快照,必须通过 putOtherTextParam 写回请求对象。 + textParamWriter.accept(APP_AUTH_TOKEN_PARAM, aliPaySetting.getAppAuthToken()); + } + } + /** * 将人民币分精确转换为保留两位小数的人民币元字符串。 * @@ -156,6 +272,73 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService return fen.movePointLeft(2).setScale(2, RoundingMode.UNNECESSARY).toPlainString(); } + /** + * 按整数分计算平台抽佣金额,并拒绝无法形成至少一分钱有效分账的配置。 + * + * @param orderAmount 支付订单金额,单位分 + * @param fzRatio 平台抽佣百分比 + * @return 向下取整后的平台抽佣金额,单位分 + */ + private int calculateFzAmount(Integer orderAmount, Double fzRatio) { + if (orderAmount == null || orderAmount <= 0 || fzRatio == null || fzRatio <= 0 || fzRatio > 100) { + throw new RuntimeException("支付宝直付通金额或平台抽佣比例无效"); + } + int fzAmount = BigDecimal.valueOf(orderAmount).multiply(BigDecimal.valueOf(fzRatio)) + .divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN).intValueExact(); + if (fzAmount < 1 || 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()); + if (StrUtil.hasBlank(baseUrl, callbackUrl)) { + throw new RuntimeException("支付宝支付回调地址未配置"); + } + return baseUrl + callbackUrl; + } + + /** + * 解析退款回调地址。命名账号可以单独配置地址,未配置时复用平台默认回调地址。 + * + * @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()); + if (StrUtil.hasBlank(baseUrl, callbackUrl)) { + throw new RuntimeException("支付宝退款回调地址未配置"); + } + return baseUrl + callbackUrl; + } + + /** + * 判断支付宝错误是否属于结果不确定或短暂限频,需要保持原幂等参数重试。 + * + * @param subCode 支付宝业务错误码 + * @return 可重试时返回 {@code true} + */ + private boolean isRetryableAlipayError(String subCode) { + return StrUtil.isBlank(subCode) || RETRYABLE_ALIPAY_ERROR_CODES.contains(subCode); + } + /** * 按商户订单号查询支付宝交易状态。 * @@ -171,8 +354,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService AlipayTradeQueryModel model = new AlipayTradeQueryModel(); model.setOutTradeNo(payNo); request.setBizModel(model); - request.putOtherTextParam(APP_AUTH_TOKEN_PARAM, aliPaySetting.getAppAuthToken()); - return getAlipayClient().execute(request); + putAppAuthToken(request::putOtherTextParam, aliPaySetting); + return getAlipayClient(aliPaySetting).execute(request); } catch (Exception e) { log.error("|{}|查询支付宝订单失败,payNo:{},exceptionType:{}", TAG, payNo, e.getClass().getSimpleName()); } @@ -189,12 +372,11 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService */ @Override public ThirdOrderCodePayVo precreate(ThirdAppPlusPayDto dto, MchPaySettingVo setting, LocalDateTime expireTime) { - AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.getAliPayConfig(); MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySetting(setting); ThirdPayOrderEntity payOrder = buildOrderCodePayOrder(dto, setting, expireTime); AlipayTradePrecreateRequest request = new AlipayTradePrecreateRequest(); - request.setNotifyUrl(aliPayConfig.getBaseCallbackUrl() + aliPayConfig.getPayCallbackUrl()); + request.setNotifyUrl(resolvePayNotifyUrl(aliPaySetting)); AlipayTradePrecreateModel model = new AlipayTradePrecreateModel(); model.setOutTradeNo(dto.getOutTradeNo()); @@ -205,11 +387,11 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService payOrder.setPayRep(JSONUtil.toJsonStr(model)); request.setBizModel(model); - request.putOtherTextParam(APP_AUTH_TOKEN_PARAM, aliPaySetting.getAppAuthToken()); + putAppAuthToken(request::putOtherTextParam, aliPaySetting); AlipayTradePrecreateResponse response; try { - response = getAlipayClient().execute(request); + response = getAlipayClient(aliPaySetting).execute(request); } catch (Exception exception) { payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_FAILED.getType()); payOrder.setPayRes(buildExceptionSnapshot(exception).toString()); @@ -254,8 +436,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService AlipayTradeCancelModel model = new AlipayTradeCancelModel(); model.setOutTradeNo(payNo); request.setBizModel(model); - request.putOtherTextParam(APP_AUTH_TOKEN_PARAM, aliPaySetting.getAppAuthToken()); - return getAlipayClient().execute(request); + putAppAuthToken(request::putOtherTextParam, aliPaySetting); + return getAlipayClient(aliPaySetting).execute(request); } catch (Exception exception) { log.error("|{}|撤销支付宝订单失败,payNo:{},exceptionType:{}", TAG, payNo, exception.getClass().getSimpleName()); return null; @@ -266,10 +448,12 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService * app支付 */ public String appPay(ThirdAppPlusPayDto dto, MchPaySettingVo setting) { - AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.getAliPayConfig(); LocalDateTime now = LocalDateTime.now(); MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + if (StrUtil.isBlank(aliPaySetting == null ? null : aliPaySetting.getSubMchid())) { + throw new RuntimeException("支付宝直付通二级商户标识未配置"); + } ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity(); payOrder.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType()); @@ -279,12 +463,22 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService payOrder.setUserCode(dto.getOpenid()); payOrder.setPrice(dto.getAmount()); payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType()); + payOrder.setIsBizCallback(com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.NO.getCode()); payOrder.setPayNo(dto.getOutTradeNo()); + 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%"); + } + 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()); AlipayTradeAppPayRequest request = new AlipayTradeAppPayRequest(); - request.setNotifyUrl(aliPayConfig.getBaseCallbackUrl() + aliPayConfig.getPayCallbackUrl()); + request.setNotifyUrl(resolvePayNotifyUrl(aliPaySetting)); AlipayTradeAppPayModel model = new AlipayTradeAppPayModel(); model.setOutTradeNo(dto.getOutTradeNo()); @@ -292,23 +486,38 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService model.setSubject("资安信"); // 超时时间15分钟 model.setTimeExpire(LocalDateTimeUtil.format(now.plusMinutes(15), DatePattern.NORM_DATETIME_PATTERN)); + SubMerchant subMerchant = new SubMerchant(); + subMerchant.setMerchantId(aliPaySetting.getSubMchid()); + model.setSubMerchant(subMerchant); + SettleInfo directPaySettleInfo = new SettleInfo(); + directPaySettleInfo.setSettlePeriodTime("1d"); + model.setSettleInfo(directPaySettleInfo); payOrder.setPayRep(JSONUtil.toJsonStr(model)); request.setBizModel(model); - request.putOtherTextParam("app_auth_token", aliPaySetting.getAppAuthToken()); - request.setNotifyUrl(aliPayConfig.getBaseCallbackUrl() + aliPayConfig.getPayCallbackUrl()); + putAppAuthToken(request::putOtherTextParam, aliPaySetting); + if (paymentOrderReservationService != null) { + paymentOrderReservationService.reserveAppPay(payOrder); + } try { - AlipayTradeAppPayResponse response = getAlipayClient().sdkExecute(request); + AlipayTradeAppPayResponse response = getAlipayClient(aliPaySetting).sdkExecute(request); String orderStr = response.getBody(); - payOrder.setPayTime(LocalDateTime.now()); payOrder.setPayRes(orderStr); return orderStr; } catch (Exception e) { log.error("|{}|app支付异常,payNo:{},exceptionType:{}", TAG, dto.getOutTradeNo(), e.getClass().getSimpleName()); return null; } finally { - thirdPayOrderService.save(payOrder); + if (paymentOrderReservationService != null) { + paymentOrderReservationService.finishAppPayCreate(payOrder); + } else { + // 仅供不启动 Spring 容器的既有单元测试;生产环境始终使用独立预留事务。 + thirdPayOrderService.save(payOrder); + if (paymentTaskEnqueuer != null) { + paymentTaskEnqueuer.enqueuePayQuery(payOrder.getTenantId(), payOrder.getId(), payOrder.getPayNo()); + } + } } } @@ -317,7 +526,6 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService */ @Override public boolean refund(ThirdPayOrderEntity thirdPayOrder, ThirdMiniWechatRefundDto dto) { - AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.getAliPayConfig(); String refundNo = dto.getAfterSaleId() == null ? "refund_" + thirdPayOrder.getPayNo() : "refund_" + dto.getAfterSaleId(); @@ -328,12 +536,20 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService if (ObjectUtil.isEmpty(refundFee)) { refundFee = thirdPayOrder.getPrice(); } + if (refundFee == null || refundFee <= 0 || thirdPayOrder.getPrice() == null + || refundFee > thirdPayOrder.getPrice()) { + throw new RuntimeException("支付宝退款金额必须大于0且不能超过订单金额"); + } ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity(); updateObj.setId(thirdPayOrder.getId()); + updateObj.setRefundNo(refundNo); + updateObj.setRefundTime(LocalDateTime.now()); + updateObj.setStatus(ThirdPayOrderStatusEnum.REFUNDING.getType()); + updateObj.setIsBizCallback(com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.NO.getCode()); AlipayTradeRefundRequest request = new AlipayTradeRefundRequest(); - request.setNotifyUrl(aliPayConfig.getBaseCallbackUrl() + aliPayConfig.getRefundCallbackUrl()); + request.setNotifyUrl(resolveRefundNotifyUrl(aliPaySetting)); AlipayTradeRefundModel model = new AlipayTradeRefundModel(); model.setOutTradeNo(thirdPayOrder.getPayNo()); @@ -343,35 +559,869 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService model.setOutRequestNo(refundNo); model.setQueryOptions(List.of("deposit_back_info")); + ThirdFzOrderEntity fzOrder = thirdFzOrderService == null ? null + : 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()); + 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.setAmount(fenToYuan(BigDecimal.valueOf(royaltyRefundAmount))); + refundRoyalty.setDesc("平台服务费退款"); + model.setRefundRoyaltyParameters(List.of(refundRoyalty)); + fzOrder.setFzRefundNo(refundNo); + fzOrder.setFzRefundTime(LocalDateTime.now()); + fzOrder.setFzRefundReq(JSONUtil.toJsonStr(model.getRefundRoyaltyParameters())); + } + } + updateObj.setRefundRep(JSONUtil.toJsonStr(model)); request.setBizModel(model); - request.putOtherTextParam("app_auth_token", aliPaySetting.getAppAuthToken()); - request.setNotifyUrl(aliPayConfig.getBaseCallbackUrl() + aliPayConfig.getRefundCallbackUrl()); + putAppAuthToken(request::putOtherTextParam, aliPaySetting); + boolean refundStarted = thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, thirdPayOrder.getId()) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())); + if (!refundStarted) { + ThirdPayOrderEntity latest = thirdPayOrderService.getById(thirdPayOrder.getId()); + return latest != null && ObjectUtil.equals(latest.getRefundNo(), refundNo) + && ObjectUtil.equals(latest.getStatus(), ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType()); + } + if (fzOrder != null && StrUtil.isNotBlank(fzOrder.getFzRefundNo())) { + ThirdFzOrderEntity fzRefundUpdate = new ThirdFzOrderEntity(); + fzRefundUpdate.setFzRefundNo(fzOrder.getFzRefundNo()); + fzRefundUpdate.setFzRefundTime(fzOrder.getFzRefundTime()); + fzRefundUpdate.setFzRefundReq(fzOrder.getFzRefundReq()); + fzRefundUpdate.setStatus(ThirdFzOrderStatusEnum.FZ_REFUNDING.getType()); + thirdFzOrderService.update(fzRefundUpdate, new LambdaUpdateWrapper() + .eq(ThirdFzOrderEntity::getId, fzOrder.getId()) + .eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_SUCCESS.getType())); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_REFUNDING.getType()); + if (paymentTaskEnqueuer != null) { + paymentTaskEnqueuer.enqueueFzRefundQuery(thirdPayOrder.getTenantId(), thirdPayOrder.getId(), + fzOrder.getId(), thirdPayOrder.getPayNo(), fzOrder.getFzNo(), fzOrder.getFzRefundNo()); + } + } try { - AlipayTradeRefundResponse response = getAlipayClient().execute(request); - boolean success = response.isSuccess(); - if (success) { - String orderStr = response.getBody(); - updateObj.setRefundRes(orderStr); - updateObj.setRefundNo(refundNo); - updateObj.setRefundTime(LocalDateTime.now()); - updateObj.setStatus(ThirdPayOrderStatusEnum.REFUNDING.getType()); - // 判断是否退款成功 - if (ObjectUtil.equals(response.getCode(), "10000")) { - updateObj.setStatus(ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType()); - return true; - } else { - updateObj.setStatus(ThirdPayOrderStatusEnum.REFUND_FAILED.getType()); - } + AlipayTradeRefundResponse response = getAlipayClient(aliPaySetting).execute(request); + updateObj.setRefundRes(response == null ? null : response.getBody()); + // 退款接口返回 10000 只表示请求已受理,最终结果必须通过退款查询或回调确认。 + if (response != null && !response.isSuccess() && !isRetryableAlipayError(response.getSubCode())) { + updateObj.setStatus(ThirdPayOrderStatusEnum.REFUND_FAILED.getType()); + updateObj.setIsBizCallback(com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode()); + updateAlipayFzRefundResult(fzOrder, response, ThirdFzOrderStatusEnum.FZ_REFUND_FAILED); } } catch (Exception e) { log.error("|{}|app退款异常,payNo:{},exceptionType:{}", TAG, thirdPayOrder.getPayNo(), e.getClass().getSimpleName()); } finally { - thirdPayOrderService.updateById(updateObj); + thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, thirdPayOrder.getId()) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.REFUNDING.getType())); + if (paymentTaskEnqueuer != null) { + paymentTaskEnqueuer.enqueueRefundQuery(thirdPayOrder.getTenantId(), thirdPayOrder.getId(), + thirdPayOrder.getPayNo(), refundNo); + } } return false; } + /** + * 查询支付宝退款结果,查询请求沿用支付订单快照中的账号和可选授权令牌。 + * + * @param payOrder 已进入退款中的支付订单,包含稳定退款号、请求参数和账号快照 + * @return 支付宝退款查询响应;调用异常时返回 {@code null} + */ + @Override + public AlipayTradeFastpayRefundQueryResponse queryRefund(ThirdPayOrderEntity payOrder) { + if (payOrder == null || StrUtil.hasBlank(payOrder.getPayNo(), payOrder.getRefundNo(), + payOrder.getMchPaySettingJson())) { + return null; + } + try { + MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySettingSnapshot(payOrder.getMchPaySettingJson()); + AlipayTradeFastpayRefundQueryModel model = new AlipayTradeFastpayRefundQueryModel(); + model.setOutTradeNo(payOrder.getPayNo()); + model.setOutRequestNo(payOrder.getRefundNo()); + model.setQueryOptions(List.of("gmt_refund_pay", "refund_detail_item_list")); + AlipayTradeFastpayRefundQueryRequest request = new AlipayTradeFastpayRefundQueryRequest(); + request.setBizModel(model); + putAppAuthToken(request::putOtherTextParam, aliPaySetting); + AlipayTradeFastpayRefundQueryResponse response = getAlipayClient(aliPaySetting).execute(request); + if (isAlipayRefundQueryMissing(response)) { + AlipayTradeRefundResponse retryResponse = retryAlipayRefund(payOrder, aliPaySetting); + // 重提成功只表示支付宝受理;网络异常或系统错误的结果仍未知,下一轮必须继续查询。 + if (retryResponse == null || retryResponse.isSuccess() + || isRetryableAlipayError(retryResponse.getSubCode())) { + return null; + } + response.setCode(retryResponse.getCode()); + response.setSubCode(retryResponse.getSubCode()); + response.setSubMsg(retryResponse.getSubMsg()); + } + return response; + } catch (Exception exception) { + log.warn("|{}|查询支付宝退款异常,payNo:{},exceptionType:{}", TAG, payOrder.getPayNo(), + exception.getClass().getSimpleName()); + return null; + } + } + + /** + * 判断支付宝退款查询是否尚未找到当前稳定退款请求。 + * + * @param response 支付宝退款查询响应 + * @return 成功响应缺少退款状态,或返回明确的退款/交易不存在错误码时返回 {@code true} + */ + private boolean isAlipayRefundQueryMissing(AlipayTradeFastpayRefundQueryResponse response) { + return response != null && (response.isSuccess() && StrUtil.isBlank(response.getRefundStatus()) + || ALIPAY_REFUND_NOT_FOUND_CODES.contains(response.getSubCode())); + } + + /** + * 退款查询未发现原请求时,使用订单中保存的完全相同请求参数再次提交。 + * + *

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

+ * + * @param payOrder 退款中的支付订单 + * @param aliPaySetting 支付创建时的支付宝商户配置 + * @return 支付宝重提响应;无法安全重提或调用结果未知时为空 + */ + private AlipayTradeRefundResponse retryAlipayRefund(ThirdPayOrderEntity payOrder, + MchPaySettingEntity.AliPaySetting aliPaySetting) { + if (StrUtil.isBlank(payOrder.getRefundRep())) { + return null; + } + try { + AlipayTradeRefundModel retryModel = JSONUtil.toBean(payOrder.getRefundRep(), AlipayTradeRefundModel.class); + if (retryModel == null || ObjectUtil.notEqual(retryModel.getOutRequestNo(), payOrder.getRefundNo())) { + return null; + } + AlipayTradeRefundRequest retryRequest = new AlipayTradeRefundRequest(); + retryRequest.setBizModel(retryModel); + retryRequest.setNotifyUrl(resolveRefundNotifyUrl(aliPaySetting)); + putAppAuthToken(retryRequest::putOtherTextParam, aliPaySetting); + AlipayTradeRefundResponse retryResponse = getAlipayClient(aliPaySetting).execute(retryRequest); + ThirdPayOrderEntity update = new ThirdPayOrderEntity(); + update.setRefundRes(retryResponse == null ? null : retryResponse.getBody()); + thirdPayOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, payOrder.getId()) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.REFUNDING.getType()) + .eq(ThirdPayOrderEntity::getRefundNo, payOrder.getRefundNo())); + return retryResponse; + } catch (Exception exception) { + log.warn("|{}|重试支付宝退款异常,payNo:{},exceptionType:{}", TAG, payOrder.getPayNo(), + exception.getClass().getSimpleName()); + return null; + } + } + + /** + * 保存支付宝退款中退分账部分的最终响应和状态。 + * + * @param fzOrder 原分账订单;未发生退分账时允许为空 + * @param response 支付宝退款响应 + * @param status 分账退款终态 + */ + private void updateAlipayFzRefundResult(ThirdFzOrderEntity fzOrder, AlipayTradeRefundResponse response, + ThirdFzOrderStatusEnum status) { + if (thirdFzOrderService == null || fzOrder == null + || ObjectUtil.notEqual(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_REFUNDING.getType())) { + return; + } + ThirdFzOrderEntity update = new ThirdFzOrderEntity(); + update.setFzRefundRes(response == null ? null : JSONUtil.toJsonStr(response)); + update.setStatus(status.getType()); + thirdFzOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdFzOrderEntity::getId, fzOrder.getId()) + .eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_REFUNDING.getType())); + } + + /** + * 执行支付宝直付通的两阶段结算与平台抽佣。支付成功后不足 30 秒时只保留待处理状态, + * 由数据库轮询在服务重启后继续执行;所有请求号均按支付订单稳定生成。 + * + * @param thirdPayOrder 已确认支付成功的支付订单 + */ + @Override + public void processDirectPayLifecycle(ThirdPayOrderEntity thirdPayOrder) { + if (thirdFzOrderService == null || thirdPayOrder == null + || ObjectUtil.notEqual(thirdPayOrder.getPayPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY.getType()) + || ObjectUtil.notEqual(thirdPayOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()) + || ObjectUtil.notEqual(thirdPayOrder.getEnableFz(), com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode()) + || thirdPayOrder.getPayTime() == null || thirdPayOrder.getPayTime().plusSeconds(30).isAfter(LocalDateTime.now())) { + return; + } + ThirdFzOrderEntity fzOrder = ensureDirectPayFzOrder(thirdPayOrder); + if (fzOrder != null) { + pollDirectPayLifecycle(fzOrder, thirdPayOrder); + } + } + + /** + * 预留支付宝直付通分账事实;外部请求必须由后续独立任务执行。 + * @param payOrder 已确认支付成功的支付宝订单 + * @return 已存在或新建的分账订单;订单尚未满足分账条件时为空 + */ + @Override + public ThirdFzOrderEntity ensureDirectPayFzOrder(ThirdPayOrderEntity payOrder) { + if (thirdFzOrderService == null || payOrder == null + || ObjectUtil.notEqual(payOrder.getPayPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY.getType()) + || ObjectUtil.notEqual(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()) + || ObjectUtil.notEqual(payOrder.getEnableFz(), com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode())) { + return null; + } + // 历史成功订单可能因旧实现没有写 pay_time;这里只物化本地分账事实,不访问支付宝。 + // 最早确认结算时间由任务 Worker 以“发现时间 + 30 秒”兜底,不能在这里永久阻断恢复。 + ThirdFzOrderEntity existing = thirdFzOrderService.getByPayNo(payOrder.getPayNo()); + if (existing != null) { + return existing; + } + MchPaySettingEntity paySetting = JSONUtil.toBean(payOrder.getMchPaySettingJson(), MchPaySettingEntity.class); + if (paySetting == null) { + throw new IllegalStateException("支付宝商户支付配置快照不存在"); + } + ThirdFzOrderEntity fzOrder = new ThirdFzOrderEntity(); + fzOrder.setId(payOrder.getId()); + fzOrder.setTenantId(payOrder.getTenantId()); + fzOrder.setPayNo(payOrder.getPayNo()); + fzOrder.setFzNo("fz_" + payOrder.getPayNo()); + fzOrder.setPrice(calculateFzAmount(payOrder.getPrice(), paySetting.getFzRatio())); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_UNPAID.getType()); + fzOrder.setFzTime(LocalDateTime.now()); + fzOrder.setCreateRes(JSONUtil.createObj().set("provider", "ALIPAY").toString()); + try { + thirdFzOrderService.save(fzOrder); + return fzOrder; + } catch (DuplicateKeyException duplicateKeyException) { + // 多实例以支付订单主键创建同一记录时,数据库唯一键保留唯一稳定分账号。 + return thirdFzOrderService.getByPayNo(payOrder.getPayNo()); + } + } + + /** + * 补偿支付宝直付通结算、平台抽佣及已分账退款。支付宝此链路没有可替代本地幂等号的轮询结果, + * 因此对超时或系统异常重复提交相同请求,显式业务失败才进入失败状态。 + * + * @param fzOrder 已持久化的分账订单 + * @param payOrder 对应的支付订单 + */ + @Override + public void pollDirectPayLifecycle(ThirdFzOrderEntity fzOrder, ThirdPayOrderEntity payOrder) { + if (thirdFzOrderService == null || fzOrder == null || payOrder == null + || StrUtil.isBlank(payOrder.getThirdPayNo()) || fzOrder.getPrice() == null) { + return; + } + if (ObjectUtil.equals(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_REFUNDING.getType())) { + pollAlipayFzRefund(fzOrder, payOrder); + return; + } + try { + String settleStatus = confirmDirectPaySettlementStage(fzOrder, payOrder); + if (!"SUCCESS".equals(settleStatus)) { + return; + } + String submitStatus = submitDirectPayRoyaltyStage(fzOrder, payOrder); + if (!List.of("SUBMITTED", "PROCESSING", "SUCCESS").contains(submitStatus)) { + return; + } + queryDirectPayRoyaltyStage(fzOrder, payOrder); + } catch (Exception exception) { + recordFzError(fzOrder, exception.getClass().getSimpleName()); + } + } + + /** + * 执行并保存确认结算单阶段;相同阶段终态重复调用不会再次请求支付宝。 + * @param fzOrder 分账订单 + * @param payOrder 支付订单 + * @return SUCCESS、PROCESSING 或 FAILED + */ + @Override + public String confirmDirectPaySettlementStage(ThirdFzOrderEntity fzOrder, ThirdPayOrderEntity payOrder) { + if (fzOrder == null || payOrder == null || ObjectUtil.notEqual(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_UNPAID.getType())) { + return resolvePersistedFzStatus(fzOrder); + } + JSONObject snapshot = readDirectPaySnapshot(fzOrder); + JSONObject existing = snapshot.getJSONObject("settleConfirm"); + String existingStatus = existing == null ? null : existing.getStr("status"); + // List.of 不允许用 null 做 contains 入参;首次执行阶段快照尚不存在,必须继续发起确认结算。 + if ("SUCCESS".equals(existingStatus) || "FAILED".equals(existingStatus)) { + return existingStatus; + } + MchPaySettingEntity.AliPaySetting aliPaySetting; + try { + aliPaySetting = requireAliPaySetting(payOrder); + } catch (RuntimeException exception) { + JSONObject failedStage = failedDirectPayStage(exception.getMessage()); + snapshot.set("settleConfirm", failedStage); + saveDirectPayStage(fzOrder, snapshot, failedStage, ThirdFzOrderStatusEnum.FZ_FAILED); + return "FAILED"; + } + JSONObject stage = confirmDirectPaySettlement(payOrder, aliPaySetting); + snapshot.set("settleConfirm", stage); + String status = stage.getStr("status"); + saveDirectPayStage(fzOrder, snapshot, stage, + "FAILED".equals(status) ? ThirdFzOrderStatusEnum.FZ_FAILED : null); + return status; + } + + /** + * 提交平台抽佣单阶段;提交成功只记录 SUBMITTED,最终成功由查询阶段裁定。 + * @param fzOrder 分账订单 + * @param payOrder 支付订单 + * @return SUBMITTED、PROCESSING 或 FAILED + */ + @Override + public String submitDirectPayRoyaltyStage(ThirdFzOrderEntity fzOrder, ThirdPayOrderEntity payOrder) { + if (fzOrder == null || payOrder == null || ObjectUtil.notEqual(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_UNPAID.getType())) { + return resolvePersistedFzStatus(fzOrder); + } + JSONObject snapshot = readDirectPaySnapshot(fzOrder); + JSONObject settleConfirm = snapshot.getJSONObject("settleConfirm"); + if (settleConfirm == null || !"SUCCESS".equals(settleConfirm.getStr("status"))) { + return "PROCESSING"; + } + JSONObject existing = snapshot.getJSONObject("royalty"); + String existingStatus = existing == null ? null : existing.getStr("status"); + // 首次抽佣没有历史状态,使用空安全比较避免把正常首调误当作系统异常。 + if ("SUBMITTED".equals(existingStatus) || "SUCCESS".equals(existingStatus) + || "FAILED".equals(existingStatus)) { + return existingStatus; + } + MchPaySettingEntity mchPaySetting; + MchPaySettingEntity.AliPaySetting aliPaySetting; + try { + mchPaySetting = requireMchPaySetting(payOrder); + aliPaySetting = requireAliPaySetting(mchPaySetting); + } catch (RuntimeException exception) { + JSONObject failedStage = failedDirectPayStage(exception.getMessage()); + snapshot.set("royalty", failedStage); + saveDirectPayStage(fzOrder, snapshot, failedStage, ThirdFzOrderStatusEnum.FZ_FAILED); + return "FAILED"; + } + JSONObject stage = settleDirectPayRoyalty(payOrder, mchPaySetting, aliPaySetting, fzOrder); + snapshot.set("royalty", stage); + String status = stage.getStr("status"); + saveDirectPayStage(fzOrder, snapshot, stage, + "FAILED".equals(status) ? ThirdFzOrderStatusEnum.FZ_FAILED : null); + return status; + } + + /** + * 查询平台抽佣最终结果;提交响应成功本身不能进入最终分账成功。 + * @param fzOrder 分账订单 + * @param payOrder 支付订单 + * @return SUCCESS、PROCESSING 或 FAILED + */ + @Override + public String queryDirectPayRoyaltyStage(ThirdFzOrderEntity fzOrder, ThirdPayOrderEntity payOrder) { + if (fzOrder == null || payOrder == null || ObjectUtil.notEqual(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_UNPAID.getType())) { + return resolvePersistedFzStatus(fzOrder); + } + JSONObject snapshot = readDirectPaySnapshot(fzOrder); + JSONObject royalty = snapshot.getJSONObject("royalty"); + if (royalty == null || StrUtil.isBlank(royalty.getStr("outRequestNo"))) { + return "PROCESSING"; + } + String currentStatus = royalty.getStr("status"); + if ("SUCCESS".equals(currentStatus)) { + markDirectPayFzSuccess(fzOrder, payOrder); + return "SUCCESS"; + } + if ("FAILED".equals(currentStatus)) { + return "FAILED"; + } + MchPaySettingEntity.AliPaySetting aliPaySetting; + try { + aliPaySetting = requireAliPaySetting(payOrder); + } catch (RuntimeException exception) { + JSONObject failedStage = failedDirectPayStage(exception.getMessage()); + royalty.set("query", failedStage); + royalty.set("status", "FAILED"); + royalty.set("error", exception.getMessage()); + snapshot.set("royalty", royalty); + saveDirectPayStage(fzOrder, snapshot, royalty, ThirdFzOrderStatusEnum.FZ_FAILED); + return "FAILED"; + } + JSONObject query = queryDirectPayRoyalty(payOrder, aliPaySetting, royalty.getStr("outRequestNo")); + if (royalty.get("submitResponse") == null && royalty.get("response") != null) { + royalty.set("submitResponse", royalty.get("response")); + } + royalty.set("query", query); + royalty.set("response", query.get("response")); + royalty.set("externalStatus", query.get("externalStatus")); + royalty.set("status", query.getStr("status")); + royalty.set("lastQueryTime", query.getStr("lastQueryTime")); + if (StrUtil.isNotBlank(query.getStr("error"))) { + royalty.set("error", query.getStr("error")); + } else { + royalty.remove("error"); + } + snapshot.set("royalty", royalty); + String status = royalty.getStr("status"); + boolean saved = saveDirectPayStage(fzOrder, snapshot, royalty, + "SUCCESS".equals(status) ? ThirdFzOrderStatusEnum.FZ_SUCCESS + : "FAILED".equals(status) ? ThirdFzOrderStatusEnum.FZ_FAILED : null); + if (saved && "SUCCESS".equals(status)) { + markDirectPayFzSuccess(fzOrder, payOrder); + } + return status; + } + + /** 读取并补齐支付宝分账快照根节点。 */ + private JSONObject readDirectPaySnapshot(ThirdFzOrderEntity fzOrder) { + JSONObject snapshot = StrUtil.isBlank(fzOrder.getCreateRes()) + ? JSONUtil.createObj() : JSONUtil.parseObj(fzOrder.getCreateRes()); + snapshot.set("provider", "ALIPAY"); + return snapshot; + } + + /** 读取支付订单创建时保存的商户设置快照。 */ + private MchPaySettingEntity requireMchPaySetting(ThirdPayOrderEntity payOrder) { + MchPaySettingEntity setting = payOrder == null || StrUtil.isBlank(payOrder.getMchPaySettingJson()) + ? null : JSONUtil.toBean(payOrder.getMchPaySettingJson(), MchPaySettingEntity.class); + if (setting == null) { + throw new IllegalStateException("支付宝商户支付配置快照不存在"); + } + 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); + if (aliPaySetting == null || StrUtil.isBlank(aliPaySetting.getSubMchid())) { + throw new IllegalStateException("支付宝直付通二级商户标识未配置"); + } + return aliPaySetting; + } + + /** 将分账订单终态转换为支付宝阶段状态。 */ + private String resolvePersistedFzStatus(ThirdFzOrderEntity fzOrder) { + if (fzOrder == null) { + return "FAILED"; + } + if (ObjectUtil.equals(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_SUCCESS.getType())) { + return "SUCCESS"; + } + if (ObjectUtil.equals(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_FAILED.getType())) { + return "FAILED"; + } + return "PROCESSING"; + } + + /** 创建不含账号、密钥和授权令牌的确定性失败阶段快照。 */ + private JSONObject failedDirectPayStage(String error) { + return JSONUtil.createObj().set("status", "FAILED") + .set("error", StrUtil.blankToDefault(error, "支付宝直付通配置无效")) + .set("lastQueryTime", LocalDateTime.now().toString()); + } + + /** + * 原子保存直付通阶段快照及可选终态,禁止较晚返回的旧请求覆盖并发实例已写入的成功或退款状态。 + * + * @param fzOrder 当前轮询读取的待处理分账订单 + * @param snapshot 合并确认结算和平台抽佣后的结构化快照 + * @param stage 本轮刚完成的阶段,用于提取请求和错误摘要 + * @param terminalStatus 本轮明确得到的最终状态;处理中时为空 + * @return 仅当数据库记录仍处于待处理状态且本次更新成功时返回 {@code true} + */ + private boolean saveDirectPayStage(ThirdFzOrderEntity fzOrder, JSONObject snapshot, JSONObject stage, + ThirdFzOrderStatusEnum terminalStatus) { + String expectedCreateRes = fzOrder.getCreateRes(); + ThirdFzOrderEntity update = new ThirdFzOrderEntity(); + update.setCreateRes(snapshot.toString()); + JSONObject request = stage == null ? null : stage.getJSONObject("request"); + if (request != null) { + update.setCreateReq(request.toString()); + } + if (stage != null && StrUtil.isNotBlank(stage.getStr("error"))) { + update.setCreateError(stage.getStr("error")); + } + if (terminalStatus != null) { + update.setStatus(terminalStatus.getType()); + } + LambdaUpdateWrapper condition = new LambdaUpdateWrapper() + .eq(ThirdFzOrderEntity::getId, fzOrder.getId()) + .eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_UNPAID.getType()); + // 阶段快照本身也是并发版本号:旧 Worker 不能用缺少后续阶段的 JSON 覆盖新 Worker 的结果。 + if (expectedCreateRes == null) { + condition.isNull(ThirdFzOrderEntity::getCreateRes); + } else { + condition.eq(ThirdFzOrderEntity::getCreateRes, expectedCreateRes); + } + boolean changed = thirdFzOrderService.update(update, condition); + if (!changed) { + return false; + } + // 同一调用后续阶段继续使用本轮已落库快照;实体只作为局部状态,不参与数据库整行覆盖。 + fzOrder.setCreateRes(update.getCreateRes()); + if (update.getCreateReq() != null) { + fzOrder.setCreateReq(update.getCreateReq()); + } + if (update.getCreateError() != null) { + fzOrder.setCreateError(update.getCreateError()); + } + if (terminalStatus != null) { + fzOrder.setStatus(terminalStatus.getType()); + } + return true; + } + + /** + * 查询支付宝退款并同步退分账终态;退款查询至少由调度任务在发起十秒后执行。 + * + * @param fzOrder 退分账中的分账订单 + * @param payOrder 对应退款中的支付订单 + */ + private void pollAlipayFzRefund(ThirdFzOrderEntity fzOrder, ThirdPayOrderEntity payOrder) { + if (!List.of(ThirdPayOrderStatusEnum.REFUNDING.getType(), ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType()) + .contains(payOrder.getStatus()) + || StrUtil.isBlank(payOrder.getRefundNo())) { + return; + } + AlipayTradeFastpayRefundQueryResponse response = queryRefund(payOrder); + if (response == null) { + return; + } + JSONObject snapshot = JSONUtil.createObj(); + snapshot.set("lastQueryTime", LocalDateTime.now().toString()); + snapshot.set("externalStatus", StrUtil.firstNonBlank(response.getRefundStatus(), response.getSubCode(), response.getCode())); + snapshot.set("response", JSONUtil.parseObj(JSONUtil.toJsonStr(response))); + ThirdFzOrderEntity update = new ThirdFzOrderEntity(); + update.setFzRefundRes(snapshot.toString()); + if ("REFUND_SUCCESS".equals(response.getRefundStatus())) { + boolean royaltySuccess = response.getRefundRoyaltys() != null && !response.getRefundRoyaltys().isEmpty() + && response.getRefundRoyaltys().stream().allMatch(item -> "SUCCESS".equals(item.getResultCode())); + update.setStatus(royaltySuccess ? ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType() + : ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType()); + if (!royaltySuccess) { + update.setFzRefundError("支付宝退款成功但退分账结果缺失或失败"); + } + } else if (!response.isSuccess() && !isAlipayRefundQueryMissing(response) + && !isRetryableAlipayError(response.getSubCode())) { + update.setStatus(ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType()); + update.setFzRefundError(StrUtil.firstNonBlank(response.getSubCode(), response.getCode(), "UNKNOWN")); + } + thirdFzOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdFzOrderEntity::getId, fzOrder.getId()) + .eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_REFUNDING.getType())); + } + + /** + * 调用支付宝确认结算接口并保存稳定请求号。 + * + * @param payOrder 支付订单 + * @param aliPaySetting 商户支付宝配置 + * @return 确认结算阶段快照 + */ + private JSONObject confirmDirectPaySettlement(ThirdPayOrderEntity payOrder, + MchPaySettingEntity.AliPaySetting aliPaySetting) { + JSONObject stage = JSONUtil.createObj(); + String outRequestNo = "settle_" + payOrder.getPayNo(); + AlipayTradeSettleConfirmModel model = new AlipayTradeSettleConfirmModel(); + model.setTradeNo(payOrder.getThirdPayNo()); + model.setOutRequestNo(outRequestNo); + SettleDetailInfo detail = new SettleDetailInfo(); + // smid 是结算主体标识而不是支付宝账户;默认结算账户由二级商户进件信息决定。 + detail.setTransInType("defaultSettle"); + detail.setSettleEntityType("SecondMerchant"); + detail.setSettleEntityId(aliPaySetting.getSubMchid()); + detail.setAmount(fenToYuan(BigDecimal.valueOf(payOrder.getPrice()))); + SettleInfo settleInfo = new SettleInfo(); + settleInfo.setSettleDetailInfos(List.of(detail)); + model.setSettleInfo(settleInfo); + SettleConfirmExtendParams extendParams = new SettleConfirmExtendParams(); + extendParams.setRoyaltyFreeze("true"); + model.setExtendParams(extendParams); + AlipayTradeSettleConfirmRequest request = new AlipayTradeSettleConfirmRequest(); + request.setBizModel(model); + putAppAuthToken(request::putOtherTextParam, aliPaySetting); + stage.set("outRequestNo", outRequestNo); + stage.set("request", JSONUtil.parseObj(JSONUtil.toJsonStr(model))); + try { + AlipayTradeSettleConfirmResponse response = getAlipayClient(aliPaySetting).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)); + if (response != null && "FAILED".equals(stage.getStr("status"))) { + stage.set("error", StrUtil.firstNonBlank(response.getSubCode(), response.getCode(), "UNKNOWN")); + } + } catch (Exception exception) { + stage.set("status", "PROCESSING"); + stage.set("error", exception.getClass().getSimpleName()); + } + stage.set("lastQueryTime", LocalDateTime.now().toString()); + return stage; + } + + /** + * 将确认结算响应映射为本地阶段状态,重复确认按成功处理,系统不确定结果继续重试。 + * + * @param response 支付宝确认结算响应 + * @return SUBMITTED、PROCESSING 或 FAILED + */ + private String resolveSettleConfirmStatus(AlipayTradeSettleConfirmResponse response) { + if (response != null && (response.isSuccess() + || "ACQ.ALREADY_CONFIRM_SETTLE".equals(response.getSubCode()))) { + return "SUCCESS"; + } + return response == null || isRetryableAlipayError(response.getSubCode()) ? "PROCESSING" : "FAILED"; + } + + /** + * 调用支付宝平台抽佣分账接口。 + * + * @param payOrder 支付订单 + * @param mchPaySetting 商户支付设置 + * @param aliPaySetting 商户支付宝配置 + * @param fzOrder 分账订单 + * @return 平台抽佣阶段快照 + */ + private JSONObject settleDirectPayRoyalty(ThirdPayOrderEntity payOrder, MchPaySettingEntity mchPaySetting, + 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())) { + stage.set("status", "FAILED"); + stage.set("error", "平台支付宝抽佣接收方未配置"); + return stage; + } + try { + validateRoyaltyAccountType(config.getRoyaltyTransInType()); + } catch (RuntimeException exception) { + stage.set("status", "FAILED"); + stage.set("error", exception.getMessage()); + return stage; + } + OpenApiRoyaltyDetailInfoPojo detail = new OpenApiRoyaltyDetailInfoPojo(); + detail.setRoyaltyType("transfer"); + detail.setTransIn(config.getRoyaltyTransIn()); + detail.setTransInType(config.getRoyaltyTransInType()); + detail.setTransInName(config.getRoyaltyTransInName()); + detail.setAmount(fenToYuan(BigDecimal.valueOf(fzOrder.getPrice()))); + detail.setDesc("平台服务费"); + AlipayTradeOrderSettleModel model = new AlipayTradeOrderSettleModel(); + model.setTradeNo(payOrder.getThirdPayNo()); + model.setOutRequestNo(outRequestNo); + model.setRoyaltyMode("sync"); + model.setRoyaltyParameters(List.of(detail)); + SettleExtendParams extendParams = new SettleExtendParams(); + // 本期只有一次平台抽佣,完成后立即释放二级商户剩余冻结余额。 + extendParams.setRoyaltyFinish("true"); + model.setExtendParams(extendParams); + AlipayTradeOrderSettleRequest request = new AlipayTradeOrderSettleRequest(); + request.setBizModel(model); + putAppAuthToken(request::putOtherTextParam, aliPaySetting); + stage.set("outRequestNo", outRequestNo); + stage.set("request", JSONUtil.parseObj(JSONUtil.toJsonStr(model))); + try { + AlipayTradeOrderSettleResponse response = getAlipayClient(aliPaySetting).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)); + if (response != null && "FAILED".equals(stage.getStr("status"))) { + stage.set("error", StrUtil.firstNonBlank(response.getSubCode(), response.getCode(), "UNKNOWN")); + } + } catch (Exception exception) { + stage.set("status", "PROCESSING"); + stage.set("error", exception.getClass().getSimpleName()); + } + stage.set("lastQueryTime", LocalDateTime.now().toString()); + return stage; + } + + /** + * 校验平台抽佣接收方账户类型,直付通分账只允许支付宝 userId 或 loginName。 + * + * @param accountType 支付宝账户类型 + */ + private void validateRoyaltyAccountType(String accountType) { + if (!"userId".equals(accountType) && !"loginName".equals(accountType)) { + throw new RuntimeException("平台支付宝抽佣接收方类型只允许userId或loginName"); + } + } + + /** + * 将平台抽佣响应映射为本地阶段状态,结算状态传播和限频按可重试处理。 + * + * @param response 支付宝平台抽佣响应 + * @return SUCCESS、PROCESSING 或 FAILED + */ + private String resolveRoyaltySubmissionStatus(AlipayTradeOrderSettleResponse response) { + if (response != null && response.isSuccess()) { + return "SUBMITTED"; + } + if (response == null || isRetryableAlipayError(response.getSubCode()) + || "ACQ.ALLOC_REFUSE_BEFORE_SETTLE".equals(response.getSubCode()) + || "ACQ.ILLEGAL_SETTLE_STATE".equals(response.getSubCode())) { + return "PROCESSING"; + } + return "FAILED"; + } + + /** + * 使用交易号和稳定抽佣请求号查询支付宝分账结果。 + * @param payOrder 支付订单 + * @param aliPaySetting 支付订单创建时保存的支付宝配置 + * @param outRequestNo 平台抽佣稳定请求号 + * @return 抽佣查询阶段快照 + */ + private JSONObject queryDirectPayRoyalty(ThirdPayOrderEntity payOrder, + MchPaySettingEntity.AliPaySetting aliPaySetting, + String outRequestNo) { + JSONObject stage = JSONUtil.createObj(); + AlipayTradeOrderSettleQueryModel model = new AlipayTradeOrderSettleQueryModel(); + model.setTradeNo(payOrder.getThirdPayNo()); + model.setOutRequestNo(outRequestNo); + AlipayTradeOrderSettleQueryRequest request = new AlipayTradeOrderSettleQueryRequest(); + request.setBizModel(model); + putAppAuthToken(request::putOtherTextParam, aliPaySetting); + stage.set("request", JSONUtil.parseObj(JSONUtil.toJsonStr(model))); + try { + AlipayTradeOrderSettleQueryResponse response = getAlipayClient(aliPaySetting).execute(request); + stage.set("response", response == null ? null : JSONUtil.parseObj(JSONUtil.toJsonStr(response))); + stage.set("externalStatus", resolveRoyaltyExternalStatus(response)); + String status = resolveRoyaltyQueryStatus(response); + stage.set("status", status); + if ("FAILED".equals(status)) { + stage.set("error", resolveRoyaltyQueryError(response)); + } + } catch (Exception exception) { + stage.set("status", "PROCESSING"); + stage.set("externalStatus", "QUERY_EXCEPTION"); + stage.set("error", exception.getClass().getSimpleName()); + } + stage.set("lastQueryTime", LocalDateTime.now().toString()); + return stage; + } + + /** + * 将支付宝抽佣查询结果映射为本地阶段状态。 + * @param response 支付宝抽佣查询响应 + * @return SUCCESS、PROCESSING 或 FAILED + */ + private String resolveRoyaltyQueryStatus(AlipayTradeOrderSettleQueryResponse response) { + if (response == null) { + return "PROCESSING"; + } + if (!response.isSuccess()) { + return isRetryableAlipayError(response.getSubCode()) ? "PROCESSING" : "FAILED"; + } + List details = response.getRoyaltyDetailList(); + if (ObjectUtil.isEmpty(details)) { + return "PROCESSING"; + } + boolean allSuccess = details.stream().allMatch(detail -> "SUCCESS".equalsIgnoreCase(detail.getState())); + if (allSuccess) { + return "SUCCESS"; + } + boolean anyFailed = details.stream().anyMatch(detail -> List.of("FAIL", "FAILED") + .contains(StrUtil.blankToDefault(detail.getState(), "").toUpperCase())); + return anyFailed ? "FAILED" : "PROCESSING"; + } + + /** 返回抽佣查询中最适合监控聚合的外部状态。 */ + private String resolveRoyaltyExternalStatus(AlipayTradeOrderSettleQueryResponse response) { + if (response == null) { + return "EMPTY_RESPONSE"; + } + if (!response.isSuccess()) { + return StrUtil.firstNonBlank(response.getSubCode(), response.getCode(), "QUERY_FAILED"); + } + if (ObjectUtil.isEmpty(response.getRoyaltyDetailList())) { + return "DETAIL_PENDING"; + } + return response.getRoyaltyDetailList().stream().map(RoyaltyDetail::getState) + .filter(StrUtil::isNotBlank).distinct().sorted().collect(java.util.stream.Collectors.joining(",")); + } + + /** 返回抽佣查询的确定性错误摘要,不包含支付宝账户或授权信息。 */ + private String resolveRoyaltyQueryError(AlipayTradeOrderSettleQueryResponse response) { + if (response == null) { + return "EMPTY_RESPONSE"; + } + if (!response.isSuccess()) { + return StrUtil.firstNonBlank(response.getSubCode(), response.getCode(), "QUERY_FAILED"); + } + return response.getRoyaltyDetailList().stream() + .filter(detail -> List.of("FAIL", "FAILED") + .contains(StrUtil.blankToDefault(detail.getState(), "").toUpperCase())) + .map(detail -> StrUtil.firstNonBlank(detail.getErrorCode(), detail.getErrorDesc(), "ROYALTY_FAILED")) + .findFirst().orElse("ROYALTY_FAILED"); + } + + /** + * 将支付宝平台抽佣成功状态原子写入支付订单,防止重复轮询累加分账金额。 + * + * @param fzOrder 分账订单 + * @param payOrder 支付订单 + */ + private void markDirectPayFzSuccess(ThirdFzOrderEntity fzOrder, ThirdPayOrderEntity payOrder) { + boolean fzSuccess = ObjectUtil.equals(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_SUCCESS.getType()); + if (!fzSuccess) { + ThirdFzOrderEntity updateFzOrder = new ThirdFzOrderEntity(); + updateFzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_SUCCESS.getType()); + fzSuccess = thirdFzOrderService.update(updateFzOrder, new LambdaUpdateWrapper() + .eq(ThirdFzOrderEntity::getId, fzOrder.getId()) + .eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_UNPAID.getType())); + if (fzSuccess) { + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_SUCCESS.getType()); + } else { + ThirdFzOrderEntity latest = thirdFzOrderService.getById(fzOrder.getId()); + fzSuccess = latest != null + && ObjectUtil.equals(latest.getStatus(), ThirdFzOrderStatusEnum.FZ_SUCCESS.getType()); + } + } + if (!fzSuccess) { + return; + } + ThirdPayOrderEntity update = new ThirdPayOrderEntity(); + update.setIsFzSuccess(com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode()); + update.setFzPrice(fzOrder.getPrice()); + thirdPayOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, payOrder.getId()) + .and(wrapper -> wrapper.isNull(ThirdPayOrderEntity::getIsFzSuccess) + .or().ne(ThirdPayOrderEntity::getIsFzSuccess, + com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode()))); + } + + /** + * 记录可重试的直付通阶段错误,不把网络异常误判为永久分账失败。 + * + * @param fzOrder 分账订单 + * @param error 错误摘要 + */ + private void recordFzError(ThirdFzOrderEntity fzOrder, String error) { + if (ObjectUtil.equals(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_SUCCESS.getType())) { + return; + } + ThirdFzOrderEntity update = new ThirdFzOrderEntity(); + update.setCreateError(error); + boolean changed = thirdFzOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdFzOrderEntity::getId, fzOrder.getId()) + .eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_UNPAID.getType())); + if (changed) { + fzOrder.setCreateError(error); + } + } + /** * 验证支付宝回调签名、应用、订单、平台和金额,并原子推进支付终态。 * @@ -451,6 +1501,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService */ @Override public ThirdPayOrderEntity refundCallback(Map params, String body, Map headers) { + if (ObjectUtil.isEmpty(params) || !verifyPayCallbackSignature(params)) { + return null; + } String paramsJson = JSONUtil.toJsonStr(params); ThirdALiPayCallbackVo vo = JSONUtil.toBean(paramsJson, ThirdALiPayCallbackVo.class); if (ObjectUtil.isEmpty(vo)) { @@ -463,6 +1516,11 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService log.error("|{}|refundCallback,意外的错误,订单不存在,支付宝返回json|{}|", TAG, JSONUtil.toJsonStr(vo)); return null; } + if (!validateCallbackAccount(params.get("app_id"), payOrderEntity)) { + log.warn("|{}|refundCallback,支付宝退款回调appId与订单账号快照不匹配,payNo:{}", TAG, + payOrderEntity.getPayNo()); + return null; + } // 一张支付单顺序发生多笔部分退款时,只处理当前退款请求号;旧通知直接确认,避免覆盖新一笔退款状态。 if (ObjectUtil.isNotEmpty(vo.getOut_biz_no()) && ObjectUtil.notEqual(payOrderEntity.getRefundNo(), vo.getOut_biz_no())) { log.warn("|{}|refundCallback,退款请求号不是当前请求,outBizNo:|{}|", TAG, vo.getOut_biz_no()); @@ -482,10 +1540,33 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService updateObj.setId(payOrderEntity.getId()); updateObj.setRefundCallbackRes(JSONUtil.toJsonStr(vo)); updateObj.setStatus(refundSucess ? ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType() : ThirdPayOrderStatusEnum.REFUND_FAILED.getType()); - thirdPayOrderService.updateById(updateObj); + if (!refundSucess) { + updateObj.setIsBizCallback(com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode()); + } + thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, payOrderEntity.getId()) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.REFUNDING.getType()) + .eq(ThirdPayOrderEntity::getRefundNo, payOrderEntity.getRefundNo())); return thirdPayOrderService.getById(payOrderEntity.getId()); } + /** + * 校验支付宝回调应用与支付订单创建时保存的命名账号快照一致。 + * + * @param callbackAppId 回调中的支付宝应用 ID + * @param payOrder 回调对应的支付订单 + * @return 回调应用与默认或命名账号完全一致时返回 {@code true} + */ + private boolean validateCallbackAccount(String callbackAppId, ThirdPayOrderEntity payOrder) { + try { + MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySettingSnapshot(payOrder.getMchPaySettingJson()); + AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(aliPaySetting); + return config != null && ObjectUtil.equals(config.getAppId(), callbackAppId); + } catch (Exception exception) { + return false; + } + } + /** * 支付消息订阅 */ @@ -508,6 +1589,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService if (ObjectUtil.isEmpty(payOrder)) { return null; } + if (StrUtil.isNotBlank(payOrder.getThirdPayNo())) { + return payOrder.getThirdPayNo(); + } if (ObjectUtil.isNotEmpty(payOrder.getPayCallbackRes())) { ThirdALiPayCallbackVo vo = JSONUtil.toBean(payOrder.getPayCallbackRes(), ThirdALiPayCallbackVo.class); if (ObjectUtil.isEmpty(vo)) { @@ -534,6 +1618,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService payOrder.setUserId(dto.getUserId()); payOrder.setPrice(dto.getAmount()); payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType()); + payOrder.setIsBizCallback(com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.NO.getCode()); payOrder.setPayNo(dto.getOutTradeNo()); payOrder.setMchId(dto.getMchId()); payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting)); @@ -542,7 +1627,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } /** - * 解析当前商户支付宝配置并确认授权令牌存在。 + * 解析当前商户支付宝配置;授权令牌允许为空,账号选择和直付通标识由具体调用继续校验。 * * @param setting 当前商户支付配置 * @return 支付宝商户授权配置 @@ -552,9 +1637,6 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService throw new RuntimeException("商户支付宝支付配置不完整"); } MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - if (aliPaySetting == null || StrUtil.isBlank(aliPaySetting.getAppAuthToken())) { - throw new RuntimeException("商户支付宝授权Token未配置"); - } return aliPaySetting; } @@ -621,13 +1703,37 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService */ protected boolean verifyPayCallbackSignature(Map params) { try { - return AlipaySignature.rsaCheckV1(params, appPlusPayConfig.getAliPayConfig().getAlipayPublicKey(), "UTF-8", "RSA2"); + AppPlusPayConfig.AliPayConfig callbackConfig = resolveCallbackAliPayConfig(params.get("app_id")); + return callbackConfig != null && AlipaySignature.rsaCheckV1(params, + callbackConfig.getAlipayPublicKey(), "UTF-8", "RSA2"); } catch (Exception exception) { log.warn("|{}|支付宝支付回调验签异常,payNo:{}", TAG, params.get("out_trade_no")); return false; } } + /** + * 按回调 app_id 查找默认或命名支付宝账号,避免使用错误公钥验签。 + * + * @param appId 支付宝回调中的应用 ID + * @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); + } + /** * 校验回调应用、商户订单、支付平台及金额均与内部订单一致。 * @@ -637,7 +1743,16 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService */ private boolean validatePayCallbackOrder(Map params, ThirdPayOrderEntity payOrder) { String payNo = payOrder.getPayNo(); - if (ObjectUtil.notEqual(params.get("app_id"), appPlusPayConfig.getAliPayConfig().getAppId())) { + MchPaySettingEntity.AliPaySetting aliPaySetting; + try { + aliPaySetting = StrUtil.isBlank(payOrder.getMchPaySettingJson()) + ? null : parseAliPaySettingSnapshot(payOrder.getMchPaySettingJson()); + } catch (Exception exception) { + log.warn("|{}|支付宝支付回调配置快照无效,payNo:{}", TAG, payNo); + return false; + } + AppPlusPayConfig.AliPayConfig expectedConfig = resolveAliPayConfig(aliPaySetting); + if (expectedConfig == null || ObjectUtil.notEqual(params.get("app_id"), expectedConfig.getAppId())) { log.warn("|{}|支付宝支付回调appId不匹配,payNo:{}", TAG, payNo); return false; } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java index c5261a81..aaefc8d9 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java @@ -6,8 +6,8 @@ import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; -import com.alipay.api.internal.util.AlipaySignature; import com.alipay.api.response.AlipayTradeCancelResponse; +import com.alipay.api.response.AlipayTradeFastpayRefundQueryResponse; import com.alipay.api.response.AlipayTradeQueryResponse; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; @@ -24,6 +24,7 @@ import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateShopStatusDto; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import com.bsd.pjcloud.hard.api.feign.RemoteHardOrderService; import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformLoginErrorEnum; @@ -108,11 +109,26 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { */ private static final String ORDER_CODE_LOCK_PREFIX = "thirdparty:order-code-pay:lock:"; + /** + * 支付成功业务后置互斥锁命名空间;租户前缀由 Redis 基础设施统一附加。 + */ + private static final String PAY_BIZ_CALLBACK_LOCK_PREFIX = "thirdparty:pay-biz-callback:lock:"; + + /** + * 退款成功业务后置互斥锁命名空间;用于隔离回调和数据库轮询的并发执行。 + */ + private static final String REFUND_BIZ_CALLBACK_LOCK_PREFIX = "thirdparty:refund-biz-callback:lock:"; + /** * 订单码幂等锁最长持有时间;覆盖正常的查询、撤销和预创建网络调用。 */ private static final Duration ORDER_CODE_LOCK_TTL = Duration.ofSeconds(30); + /** + * 跨服务业务后置互斥锁有效期;只限制并发执行,失败后仍允许数据库轮询重试。 + */ + private static final Duration BIZ_CALLBACK_LOCK_TTL = Duration.ofMinutes(2); + /** * 支付宝撤销接口要求稍后重试时返回的标记值。 */ @@ -365,17 +381,17 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { } dto.setAmount(recharge.getPayPrice()); } else if (ObjectUtil.equals(dto.getBizType(), ThirdPayOrderBizTypeEnum.APP_SHOP_PAY)) { - if (dto.getAmount() <= 0) { + if (dto.getAmount() == null || dto.getAmount() <= 0) { throw new RuntimeException("支付金额不能小于等于0"); } - dto.setAmount(dto.getAmount()); - mchManage = getUserMerchant(userId); + mchManage = resolveShopPayMerchant(userId, dto); } else { throw new RuntimeException("不支持的支付类型"); } - if (ObjectUtil.isEmpty(mchManage.getPaySettingList())) { + if (ObjectUtil.isEmpty(mchManage) || ObjectUtil.isEmpty(mchManage.getPaySettingList())) { throw new RuntimeException("商户未配置支付参数"); } + validateMerchantForPayment(mchManage); List paySettingList = mchManage.getPaySettingList(); ThirdAppPlusPayDto appPlusPayDto = new ThirdAppPlusPayDto(); @@ -393,14 +409,17 @@ 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())) { - throw new RuntimeException("商户未配置微信支付参数"); - } + MchPaySettingVo paySetting = getEnabledWechatPaySetting(mchManage); MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class); appPlusPayDto.setSubMchid(wechatPaySetting.getSubMchid()); - Map appPlusPayParams = thirdMiniAppWechatService.appPay(appPlusPayDto, paySetting); + Map appPlusPayParams; + try { + appPlusPayParams = thirdMiniAppWechatService.appPay(appPlusPayDto, paySetting); + } finally { + // 即使同步创建响应丢失,支付服务已保存的未支付订单也必须立即进入数据库补偿。 + pollingCoordinator.wakePaymentCompensationPolling(); + } if (ObjectUtil.isEmpty(appPlusPayParams)) { throw new RuntimeException("创建微信支付错误"); } @@ -408,14 +427,16 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { } // 支付宝 else if (ObjectUtil.equals(dto.getMiniPlatformType().getType(), AppPlusPlatformTypeEnum.ALI_PAY.getType())) { - MchPaySettingVo paySetting = paySettingList.stream().filter(item -> ObjectUtil.equals(item.getPlatformType(), AppPlusPlatformPayType.ALI_PAY.getType())).findFirst().orElse(null); - if (ObjectUtil.isEmpty(paySetting)) { - throw new RuntimeException("商户未配置微信支付参数"); - } + MchPaySettingVo paySetting = getEnabledAliPaySetting(mchManage); MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); appPlusPayDto.setSubMchid(aliPaySetting.getSubMchid()); - String orderStr = thirdMiniAppAliPayService.appPay(appPlusPayDto, paySetting); + String orderStr; + try { + orderStr = thirdMiniAppAliPayService.appPay(appPlusPayDto, paySetting); + } finally { + pollingCoordinator.wakePaymentCompensationPolling(); + } if (ObjectUtil.isEmpty(orderStr)) { throw new RuntimeException("创建支付宝支付错误"); } @@ -447,6 +468,74 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { return merchant; } + /** + * 解析商城支付的可信收款商户。远程硬件订单以持久化 merchant_id 为真源, + * 远程 DTO 中的商户 ID 只用于一致性校验,不能覆盖订单归属。 + * + * @param userId 当前付款用户 ID + * @param dto 小程序支付请求 + * @return 已校验的商城收款商户 + */ + private MchManageVo resolveShopPayMerchant(Long userId, ThirdMiniAppPayDto dto) { + if (!(dto instanceof ThirdMiniRemotePayDto remotePayDto) || remotePayDto.getMchId() == null) { + throw new RuntimeException("商城支付缺少可信商户信息"); + } + Long orderId; + try { + orderId = Long.valueOf(dto.getBizId()); + } catch (Exception exception) { + throw new RuntimeException("商城订单业务id解析错误"); + } + R> orderResult = remoteHardOrderService.getByIds(List.of(orderId)); + List orders = orderResult == null || !orderResult.isOk() ? null : orderResult.getData(); + HardOrderEntity order = orders == null ? null : orders.stream() + .filter(item -> item != null && ObjectUtil.equals(item.getId(), orderId)) + .findFirst().orElse(null); + if (order == null || order.getMerchantId() == null || !ObjectUtil.equals(order.getUserId(), userId)) { + throw new RuntimeException("商城订单不存在或无权支付"); + } + if (order.getPayPrice() == null || order.getPayPrice() <= 0 || ObjectUtil.notEqual(order.getPayPrice(), dto.getAmount())) { + throw new RuntimeException("商城订单支付金额不一致"); + } + if (!ObjectUtil.equals(order.getMerchantId(), remotePayDto.getMchId())) { + throw new RuntimeException("商城订单收款商户不一致"); + } + MchManageVo merchant = getMerchantById(order.getMerchantId()); + if (merchant == null) { + throw new RuntimeException("商城订单收款商户不存在"); + } + return merchant; + } + + /** + * 校验收款商户仍可用于支付。合作状态为空兼容食堂及历史商户,明确停用时拒绝新交易。 + * + * @param merchant 商户管理服务返回的收款商户 + */ + private void validateMerchantForPayment(MchManageVo merchant) { + if (merchant == null || merchant.getId() == null) { + throw new RuntimeException("收款商户不存在"); + } + if (ObjectUtil.equals(merchant.getCooperationStatus(), YesNoEnum.NO.getCode())) { + throw new RuntimeException("收款商户已停用"); + } + } + + /** + * 按商户管理服务读取指定收款商户,并校验远程调用状态。 + * + * @param mchId 商户管理主键 + * @return 商户详情;远程调用失败或 ID 不一致时返回 {@code null} + */ + private MchManageVo getMerchantById(Long mchId) { + R result = remoteMchService.getById(mchId); + MchManageVo merchant = result == null || !result.isOk() ? null : result.getData(); + if (merchant == null || !ObjectUtil.equals(merchant.getId(), mchId)) { + return null; + } + return merchant; + } + /** * 付款码支付 */ @@ -755,7 +844,13 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { } /** - * 从商户详情中选择启用的支付宝配置并校验应用授权令牌。 + * 从商户详情中选择启用的支付宝配置并校验直付通二级商户标识。 + * + *

appAuthToken 允许为空,但 smid/ip_role_id 必须来自商户服务端配置, + * 不能使用客户端上传的 subMchid 替代。

+ * + * @param merchant 已校验存在且可支付的收款商户 + * @return 当前商户启用的支付宝支付配置 */ private MchPaySettingVo getEnabledAliPaySetting(MchManageVo merchant) { MchPaySettingVo paySetting = merchant.getPaySettingList().stream() @@ -768,11 +863,11 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { } try { MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - if (aliPaySetting == null || StrUtil.isBlank(aliPaySetting.getAppAuthToken())) { - throw new RuntimeException("商户支付宝授权Token未配置"); + if (aliPaySetting == null || StrUtil.isBlank(aliPaySetting.getSubMchid())) { + throw new RuntimeException("商户支付宝直付通二级商户标识未配置"); } } catch (RuntimeException exception) { - if ("商户支付宝授权Token未配置".equals(exception.getMessage())) { + if ("商户支付宝直付通二级商户标识未配置".equals(exception.getMessage())) { throw exception; } throw new RuntimeException("商户支付宝支付配置无效"); @@ -880,22 +975,25 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { log.error("|{}|小程序退款错误,订单不存在dto:|{}|", TAG, JSONUtil.toJsonStr(dto)); throw new RuntimeException("订单不存在"); } - // 微信小程序 - if (ObjectUtil.equals(thirdPayOrder.getPayPlatformType(), AppPlusPlatformTypeEnum.WECHAT.getType())) { - thirdMiniAppWechatService.refund(thirdPayOrder, dto); - } - // 支付宝 - else if (ObjectUtil.equals(thirdPayOrder.getPayPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY.getType())) { - boolean isRefund = thirdMiniAppAliPayService.refund(thirdPayOrder, dto); - if (isRefund) { - // 再查询一下 - thirdPayOrder = thirdPayOrderService.getByPayNo(dto.getPayNo()); - disposeRefundSuccessAfter(thirdPayOrder); + try { + // 微信小程序 + if (ObjectUtil.equals(thirdPayOrder.getPayPlatformType(), AppPlusPlatformTypeEnum.WECHAT.getType())) { + ThirdPayOrderEntity refundOrder = thirdMiniAppWechatService.refund(thirdPayOrder, dto); + markRefundBusinessCallbackIfSuccessful(refundOrder); } - } - // 不支持的支付类型 - else { - throw new RuntimeException("不支持的支付类型"); + // 支付宝 + else if (ObjectUtil.equals(thirdPayOrder.getPayPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY.getType())) { + boolean isRefund = thirdMiniAppAliPayService.refund(thirdPayOrder, dto); + if (isRefund) { + markRefundBusinessCallbackIfSuccessful(thirdPayOrderService.getByPayNo(dto.getPayNo())); + } + } + // 不支持的支付类型 + else { + throw new RuntimeException("不支持的支付类型"); + } + } finally { + pollingCoordinator.wakePaymentCompensationPolling(); } return null; } @@ -931,28 +1029,32 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { refundDto.setPayNo(thirdPayOrder.getPayNo()); refundDto.setRefundPrice(dto.getPrice()); refundDto.setAfterSaleId(dto.getAfterSaleId()); - // 微信小程序 - if (ObjectUtil.equals(thirdPayOrder.getPayPlatformType(), AppPlusPlatformTypeEnum.WECHAT.getType())) { - ThirdPayOrderEntity refund = thirdMiniAppWechatService.refund(thirdPayOrder, refundDto); - if (ObjectUtil.isNotEmpty(refund)) { - return refund.getRefundNo(); + try { + // 微信小程序 + if (ObjectUtil.equals(thirdPayOrder.getPayPlatformType(), AppPlusPlatformTypeEnum.WECHAT.getType())) { + ThirdPayOrderEntity refund = thirdMiniAppWechatService.refund(thirdPayOrder, refundDto); + if (ObjectUtil.isNotEmpty(refund)) { + markRefundBusinessCallbackIfSuccessful(refund); + return refund.getRefundNo(); + } } - } - // 支付宝 - else if (ObjectUtil.equals(thirdPayOrder.getPayPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY.getType())) { - boolean isRefund = thirdMiniAppAliPayService.refund(thirdPayOrder, refundDto); - if (isRefund) { - // 再查询一下 - thirdPayOrder = thirdPayOrderService.getByBiz(dto.getBizType(), String.valueOf(dto.getOrderId())); - disposeRefundSuccessAfter(thirdPayOrder); - return thirdPayOrder.getRefundNo(); + // 支付宝 + else if (ObjectUtil.equals(thirdPayOrder.getPayPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY.getType())) { + boolean isRefund = thirdMiniAppAliPayService.refund(thirdPayOrder, refundDto); + if (isRefund) { + thirdPayOrder = thirdPayOrderService.getByBiz(dto.getBizType(), String.valueOf(dto.getOrderId())); + markRefundBusinessCallbackIfSuccessful(thirdPayOrder); + return thirdPayOrder.getRefundNo(); + } } + // 不支持的支付类型 + else { + throw new RuntimeException("不支持的支付类型"); + } + return null; + } finally { + pollingCoordinator.wakePaymentCompensationPolling(); } - // 不支持的支付类型 - else { - throw new RuntimeException("不支持的支付类型"); - } - return null; } /** @@ -973,6 +1075,7 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { if (ObjectUtil.isEmpty(thirdPayOrder)) { return "fail"; } + pollingCoordinator.wakePaymentCompensationPolling(); if (thirdAggregateOrderPayService.syncAggregateChildOrder(thirdPayOrder)) { return "success"; } @@ -988,24 +1091,21 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { if (callbackResult == null) { return ThirdALiPayCallbackVo.aliPayFail(); } + if (callbackResult.isPaySuccess()) { + pollingCoordinator.wakePaymentCompensationPolling(); + } if (thirdAggregateOrderPayService.syncAggregateChildOrder(callbackResult.getPayOrder())) { return ThirdALiPayCallbackVo.aliPaySuccess(); } - // 已关闭通知和重复成功通知均已完成平台确认,不再重复触发下游业务处理。 - if (!callbackResult.isPaySuccess() || !callbackResult.isStateChanged()) { + // 关闭和等待支付通知不触发业务后置;重复成功通知允许补偿上一次失败的业务调用。 + if (!callbackResult.isPaySuccess()) { return ThirdALiPayCallbackVo.aliPaySuccess(); } ThirdPayOrderEntity thirdPayOrder = callbackResult.getPayOrder(); - // 根据订单bizType来处理 - boolean isSuccess = disposePaySuccessAfter(thirdPayOrder); - // 更新 - if (isSuccess) { - ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity(); - updateObj.setId(thirdPayOrder.getId()); - updateObj.setIsBizCallback(YesNoEnum.YES.getCode()); - thirdPayOrderService.updateById(updateObj); - } - return isSuccess ? ThirdALiPayCallbackVo.aliPaySuccess() : ThirdALiPayCallbackVo.aliPayFail(); + markBusinessCallbackIfSuccessful(thirdPayOrder); + ThirdPayOrderEntity latestOrder = thirdPayOrderService.getById(thirdPayOrder.getId()); + return latestOrder != null && ObjectUtil.equals(latestOrder.getIsBizCallback(), YesNoEnum.YES.getCode()) + ? ThirdALiPayCallbackVo.aliPaySuccess() : ThirdALiPayCallbackVo.aliPayFail(); } // 不支持的支付类型 else { @@ -1016,7 +1116,8 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { /** * 主动查询支付平台状态并以未支付条件原子推进内部订单状态。 * - *

订单码到期仍待支付时会调用对应平台的撤销或关单接口;网络错误和可重试结果均保留未支付状态。

+ *

任何保存了截止时间的非聚合支付订单到期后,都会先查单再调用对应平台的撤销或关单接口; + * 网络错误和可重试结果均保留未支付状态。

* * @param dto 需要查询的内部支付订单 */ @@ -1029,6 +1130,15 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { if (thirdAggregateOrderPayService.syncAggregateChildOrder(dto)) { return; } + // 平台终态已经落库但进程在业务后置前退出时,直接补偿业务,不再依赖本轮渠道查单成功。 + if (ObjectUtil.equals(dto.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) { + markBusinessCallbackIfSuccessful(dto); + return; + } + if (ObjectUtil.equals(dto.getStatus(), ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType())) { + markRefundBusinessCallbackIfSuccessful(dto); + return; + } // 微信支付:订单码使用 Native 服务,原有 App/小程序订单继续沿用既有查询服务。 if (ObjectUtil.equals(dto.getPayPlatformType(), AppPlusPlatformTypeEnum.WECHAT.getType())) { if (isOrderCodePay(dto)) { @@ -1046,6 +1156,10 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { && ObjectUtil.notEqual(dto.getIsBizCallback(), YesNoEnum.YES.getCode()))) { markBusinessCallbackIfSuccessful(thirdPayOrderService.getById(dto.getId())); } + } else if (ObjectUtil.equals(tradeState, Transaction.TradeStateEnum.CLOSED)) { + updateUnpaidOrderToClosed(dto.getId()); + } else if (isExpiredUnpaidPayOrder(dto)) { + closeExpiredPayOrder(dto); } } // 支付宝 @@ -1063,8 +1177,77 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { } } else if (ObjectUtil.equals(tradeStatus, "TRADE_CLOSED")) { updateUnpaidOrderToClosed(dto.getId()); - } else if (isExpiredOrderCodePay(dto)) { - closeExpiredOrderCodePay(dto); + } else if (isExpiredUnpaidPayOrder(dto)) { + closeExpiredPayOrder(dto); + } + } + } + + /** + * 查询微信或支付宝普通退款状态。回调和轮询均只允许从 REFUNDING 推进,避免重复触发业务退款后置处理。 + * + * @param dto 当前退款中的支付订单 + */ + @Override + public void queryRefundResult(ThirdPayOrderEntity dto) { + if (dto == null || dto.getId() == null || ObjectUtil.notEqual(dto.getStatus(), ThirdPayOrderStatusEnum.REFUNDING.getType())) { + return; + } + if (ObjectUtil.equals(dto.getPayPlatformType(), AppPlusPlatformTypeEnum.WECHAT.getType())) { + ThirdMiniWechatRefundQueryVo query = thirdMiniAppWechatService.refundQuery(dto.getPayNo()); + if (query == null || StrUtil.isBlank(query.getRefund_status())) { + return; + } + String status = query.getRefund_status(); + if ("PROCESSING".equals(status)) { + return; + } + boolean refundSuccess = "SUCCESS".equals(status); + boolean refundFailed = "CLOSED".equals(status) || "ABNORMAL".equals(status); + if (!refundSuccess && !refundFailed) { + return; + } + String localStatus = refundSuccess ? ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType() + : ThirdPayOrderStatusEnum.REFUND_FAILED.getType(); + ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity(); + updateObj.setRefundCallbackRes(JSONUtil.toJsonStr(query)); + updateObj.setStatus(localStatus); + if (refundFailed) { + updateObj.setIsBizCallback(YesNoEnum.YES.getCode()); + } + boolean changed = thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, dto.getId()) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.REFUNDING.getType()) + .eq(ThirdPayOrderEntity::getRefundNo, dto.getRefundNo())); + if (changed && refundSuccess) { + markRefundBusinessCallbackIfSuccessful(thirdPayOrderService.getById(dto.getId())); + } + } else if (ObjectUtil.equals(dto.getPayPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY.getType())) { + AlipayTradeFastpayRefundQueryResponse query = thirdMiniAppAliPayService.queryRefund(dto); + if (query == null) { + return; + } + boolean refundSuccess = "REFUND_SUCCESS".equals(query.getRefundStatus()); + boolean retryable = (query.isSuccess() && StrUtil.isBlank(query.getRefundStatus())) + || StrUtil.isBlank(query.getSubCode()) + || List.of("ACQ.SYSTEM_ERROR", "ACQ.TRADE_SETTLE_ERROR", "ACQ.FREQUENCY_LIMITED") + .contains(query.getSubCode()); + if (!refundSuccess && retryable) { + return; + } + ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity(); + updateObj.setRefundCallbackRes(JSONUtil.toJsonStr(query)); + updateObj.setStatus(refundSuccess ? ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType() + : ThirdPayOrderStatusEnum.REFUND_FAILED.getType()); + if (!refundSuccess) { + updateObj.setIsBizCallback(YesNoEnum.YES.getCode()); + } + boolean changed = thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, dto.getId()) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.REFUNDING.getType()) + .eq(ThirdPayOrderEntity::getRefundNo, dto.getRefundNo())); + if (changed && refundSuccess) { + markRefundBusinessCallbackIfSuccessful(thirdPayOrderService.getById(dto.getId())); } } } @@ -1081,9 +1264,14 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { updateObj.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); updateObj.setPayTime(LocalDateTime.now()); updateObj.setThirdPayNo(tradeNo); - return thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper() + updateObj.setIsBizCallback(YesNoEnum.NO.getCode()); + boolean changed = thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper() .eq(ThirdPayOrderEntity::getId, payOrder.getId()) .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType())); + if (changed) { + pollingCoordinator.wakePaymentCompensationPolling(); + } + return changed; } /** @@ -1120,7 +1308,7 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { return; } if (isExpiredOrderCodePay(payOrder)) { - closeExpiredOrderCodePay(payOrder); + closeExpiredPayOrder(payOrder); } } @@ -1173,18 +1361,35 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { */ private boolean isExpiredOrderCodePay(ThirdPayOrderEntity payOrder) { return isOrderCodePay(payOrder) + && isExpiredUnpaidPayOrder(payOrder); + } + + /** + * 判断支付订单是否已经超过渠道下单时使用的截止时间。 + * + * @param payOrder 当前内部支付订单 + * @return 订单仍未支付、保存了截止时间且已经到期时返回 {@code true} + */ + private boolean isExpiredUnpaidPayOrder(ThirdPayOrderEntity payOrder) { + return payOrder != null && payOrder.getExpireTime() != null && ObjectUtil.equals(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType()) && !payOrder.getExpireTime().isAfter(LocalDateTime.now()); } /** - * 关闭已经到期的支付宝或微信订单码;需重试、网络异常或未知结果均保留未支付状态。 + * 关闭已经到期的支付宝、微信 App 或微信订单码;需重试、网络异常或未知结果均保留未支付状态。 * - * @param payOrder 已到期且查询未确认成功的订单码支付订单 + *

微信订单码使用 Native 关单接口,普通 App 支付使用 App 关单接口;支付宝两类订单均通过 + * 撤销接口确认 {@code close/refund} 动作后才推进本地状态。

+ * + * @param payOrder 已到期且查单未确认成功的支付订单 */ - private void closeExpiredOrderCodePay(ThirdPayOrderEntity payOrder) { + private void closeExpiredPayOrder(ThirdPayOrderEntity payOrder) { if (ObjectUtil.equals(payOrder.getPayPlatformType(), AppPlusPlatformTypeEnum.WECHAT.getType())) { - if (thirdAppPlusWechatNativePayService.close(payOrder.getPayNo(), payOrder.getMchPaySettingJson())) { + boolean closed = isOrderCodePay(payOrder) + ? thirdAppPlusWechatNativePayService.close(payOrder.getPayNo(), payOrder.getMchPaySettingJson()) + : thirdMiniAppWechatService.close(payOrder.getPayNo(), payOrder.getMchPaySettingJson()); + if (closed) { updateUnpaidOrderToClosed(payOrder.getId()); } return; @@ -1207,12 +1412,67 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { || ObjectUtil.equals(payOrder.getIsBizCallback(), YesNoEnum.YES.getCode())) { return; } - if (disposePaySuccessAfter(payOrder)) { - ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity(); - updateObj.setIsBizCallback(YesNoEnum.YES.getCode()); - thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper() - .eq(ThirdPayOrderEntity::getId, payOrder.getId()) - .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())); + String lockKey = PAY_BIZ_CALLBACK_LOCK_PREFIX + payOrder.getId(); + String lockToken = UUID.randomUUID().toString(); + Boolean lockAcquired = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockToken, BIZ_CALLBACK_LOCK_TTL); + if (!Boolean.TRUE.equals(lockAcquired)) { + return; + } + try { + ThirdPayOrderEntity latest = thirdPayOrderService.getById(payOrder.getId()); + if (latest == null || ObjectUtil.notEqual(latest.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()) + || ObjectUtil.equals(latest.getIsBizCallback(), YesNoEnum.YES.getCode())) { + return; + } + if (disposePaySuccessAfter(latest)) { + thirdPayOrderService.update(new ThirdPayOrderEntity(), new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, latest.getId()) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()) + .and(wrapper -> wrapper.isNull(ThirdPayOrderEntity::getIsBizCallback) + .or().ne(ThirdPayOrderEntity::getIsBizCallback, YesNoEnum.YES.getCode())) + .set(ThirdPayOrderEntity::getIsBizCallback, YesNoEnum.YES.getCode())); + } + } finally { + releaseOrderCodePayLock(lockKey, lockToken); + } + } + + /** + * 退款成功后串行执行跨服务业务后置,并在成功后原子记录完成标识。 + * + *

Redis 锁只消除回调与轮询的并发重复;进程在远程成功后、本地标记前退出时仍会重试, + * 下游继续使用稳定 refundNo 保证业务幂等。

+ * + * @param payOrder 已确认退款成功的最新内部订单 + */ + private void markRefundBusinessCallbackIfSuccessful(ThirdPayOrderEntity payOrder) { + if (payOrder == null || ObjectUtil.notEqual(payOrder.getStatus(), ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType()) + || ObjectUtil.equals(payOrder.getIsBizCallback(), YesNoEnum.YES.getCode())) { + return; + } + String lockKey = REFUND_BIZ_CALLBACK_LOCK_PREFIX + payOrder.getId(); + String lockToken = UUID.randomUUID().toString(); + Boolean lockAcquired = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockToken, BIZ_CALLBACK_LOCK_TTL); + if (!Boolean.TRUE.equals(lockAcquired)) { + return; + } + try { + ThirdPayOrderEntity latest = thirdPayOrderService.getById(payOrder.getId()); + if (latest == null || ObjectUtil.notEqual(latest.getStatus(), ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType()) + || ObjectUtil.equals(latest.getIsBizCallback(), YesNoEnum.YES.getCode())) { + return; + } + if (disposeRefundSuccessAfter(latest)) { + thirdPayOrderService.update(new ThirdPayOrderEntity(), new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, latest.getId()) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType()) + .eq(ThirdPayOrderEntity::getRefundNo, latest.getRefundNo()) + .and(wrapper -> wrapper.isNull(ThirdPayOrderEntity::getIsBizCallback) + .or().ne(ThirdPayOrderEntity::getIsBizCallback, YesNoEnum.YES.getCode())) + .set(ThirdPayOrderEntity::getIsBizCallback, YesNoEnum.YES.getCode())); + } + } finally { + releaseOrderCodePayLock(lockKey, lockToken); } } @@ -1228,9 +1488,8 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { if (ObjectUtil.isEmpty(thirdPayOrder)) { return ThirdMiniWechantRefunCallbackVo.wechantError(); } - // 根据订单bizType来处理 - boolean isSuccess = disposeRefundSuccessAfter(thirdPayOrder); - return isSuccess ? "success" : "fail"; + markRefundBusinessCallbackIfSuccessful(thirdPayOrder); + return "success"; } // 支付宝 else if (ObjectUtil.equals(platformType, AppPlusPlatformTypeEnum.ALI_PAY)) { @@ -1238,9 +1497,8 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { if (ObjectUtil.isEmpty(thirdPayOrder)) { return ThirdALiPayCallbackVo.aliPayFail(); } - // 根据订单bizType来处理 - boolean isSuccess = disposeRefundSuccessAfter(thirdPayOrder); - return isSuccess ? ThirdALiPayCallbackVo.aliPaySuccess() : ThirdALiPayCallbackVo.aliPayFail(); + markRefundBusinessCallbackIfSuccessful(thirdPayOrder); + return ThirdALiPayCallbackVo.aliPaySuccess(); } // 不支持的支付类型 else { @@ -1261,7 +1519,7 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { } /** - * 识别共享支付回调所属平台;支付宝分支先执行 RSA2 验签再返回平台类型。 + * 识别共享支付回调所属平台;支付宝的账号选择和 RSA2 验签由支付宝服务按 app_id 完成。 * * @param params 表单回调参数 * @param body 原始请求体 @@ -1273,15 +1531,8 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { // 微信 if (ObjectUtil.isNotEmpty(transaction)) return AppPlusPlatformTypeEnum.WECHAT; // 支付宝 - if (ObjectUtil.isNotEmpty(params)) { - try { - boolean flag = AlipaySignature.rsaCheckV1(params, appPlusPayConfig.getAliPayConfig().getAlipayPublicKey(), "UTF-8", "RSA2"); - if (flag) { - return AppPlusPlatformTypeEnum.ALI_PAY; - } - } catch (Exception e) { - log.warn("|{}|判断支付宝支付回调验签异常,payNo:{}", TAG, params.get("out_trade_no")); - } + if (ObjectUtil.isNotEmpty(params) && StrUtil.isNotBlank(params.get("out_trade_no"))) { + return AppPlusPlatformTypeEnum.ALI_PAY; } return null; } @@ -1294,15 +1545,8 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { // 微信 if (ObjectUtil.isNotEmpty(refundNotification)) return AppPlusPlatformTypeEnum.WECHAT; // 支付宝 - if (ObjectUtil.isNotEmpty(params)) { - try { - boolean flag = AlipaySignature.rsaCheckV1(params, appPlusPayConfig.getAliPayConfig().getAlipayPublicKey(), "UTF-8", "RSA2"); - if (flag) { - return AppPlusPlatformTypeEnum.ALI_PAY; - } - } catch (Exception e) { - log.error("|{}|判断退款回调alipay sdk验签错误,body:|{}|params:{||}", TAG, body, JSONUtil.toJsonStr(params)); - } + if (ObjectUtil.isNotEmpty(params) && StrUtil.isNotBlank(params.get("out_trade_no"))) { + return AppPlusPlatformTypeEnum.ALI_PAY; } return null; } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatMicropayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatMicropayServiceImpl.java index 0d5ffd7c..cd36b7e3 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatMicropayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatMicropayServiceImpl.java @@ -13,7 +13,7 @@ import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; 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.impl.appplus.fz.lifecycle.ThirdAppPlusWechatFzLifecycle; +import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer; 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; @@ -31,9 +31,6 @@ import org.springframework.validation.annotation.Validated; import java.time.LocalDateTime; import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; /** * 微信付款码支付(只支持v2,所以单独写一个服务) @@ -53,9 +50,8 @@ public class ThirdAppPlusWechatMicropayServiceImpl implements ThirdAppPlusWechat private final WxPayService wxPayService; - private final ThirdAppPlusWechatFzLifecycle appPlusWechatFzLifecycle; - - private static final ExecutorService executorService = Executors.newFixedThreadPool(10); + /** 支付成功后写入数据库分账任务,不创建进程内延迟线程。 */ + private final ThirdPaymentTaskEnqueuer paymentTaskEnqueuer; /** * 付款码支付 @@ -131,21 +127,13 @@ public class ThirdAppPlusWechatMicropayServiceImpl implements ThirdAppPlusWechat return null; } finally { thirdPayOrderService.save(payOrder); - // 订单支付成功 - CompletableFuture.runAsync(() -> { - try { - if (ObjectUtil.notEqual(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) { - return; - } - Thread.sleep(30000); - appPlusWechatFzLifecycle.orderPaySuccess(thirdPayOrderService.getById(payOrder.getId())); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.error("|{}|延迟调用orderPaySuccess被中断", TAG, e); - } catch (Exception e) { - log.error("|{}|延迟调用orderPaySuccess异常", TAG, e); - } - }, executorService); + if (ObjectUtil.equals(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()) + && payOrder.getPayTime() != null) { + paymentTaskEnqueuer.enqueueAt(payOrder.getTenantId(), + com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum.FZ_CREATE, + "FZ_CREATE:" + payOrder.getPayNo(), payOrder.getId(), payOrder.getPayNo(), null, null, null, + payOrder.getPayTime().plusSeconds(30), 90); + } } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImpl.java index 514da37b..3636768a 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImpl.java @@ -22,11 +22,14 @@ import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; 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.impl.appplus.fz.lifecycle.ThirdAppPlusWechatFzLifecycle; import com.bsd.pjcloud.thirdparty.utils.pay.wechat.ThirdAppPlusWechantPayConstant; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.wechat.*; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import com.wechat.pay.java.core.exception.ServiceException; import com.wechat.pay.java.core.notification.NotificationParser; import com.wechat.pay.java.core.notification.RequestParam; import com.wechat.pay.java.service.partnerpayments.app.AppServiceExtension; @@ -40,7 +43,6 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.codec.digest.DigestUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; import javax.crypto.Cipher; @@ -49,10 +51,9 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; /** * 微信小程序(app版本) @@ -84,13 +85,24 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService @Autowired(required = false) private ThirdAggregatePaySessionService aggregatePaySessionService; - private static final ExecutorService executorService = Executors.newFixedThreadPool(10); + /** 实时写入支付查单和退款查单任务,不负责周期调度。 */ + @Autowired(required = false) + private ThirdPaymentTaskEnqueuer paymentTaskEnqueuer; + + /** 支付订单与首任务预留事务;可选注入仅用于兼容既有纯单元测试构造方式。 */ + @Autowired(required = false) + private ThirdPaymentOrderReservationService paymentOrderReservationService; /** - * APP支付 + * 创建微信服务商 App 支付,子商户号和分账比例只接受服务端商户配置快照。 + * + * @param dto 已由支付编排校验的业务订单、用户和金额 + * @param setting 当前商户启用的微信支付配置 + * @return 微信 App 调起参数;渠道调用异常时返回空并保留未支付订单供轮询恢复 */ @Override public Map appPay(ThirdAppPlusPayDto dto, MchPaySettingVo setting) { + MchPaySettingEntity.WechatPaySetting wechatPaySetting = validateWechatAppPaySetting(dto, setting); AppPlusPayConfig.WechatConfig wechatConfig = appPlusPayConfig.getWechatConfig(); ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity(); @@ -101,7 +113,10 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService payOrder.setUserCode(dto.getOpenid()); payOrder.setPrice(dto.getAmount()); payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType()); + payOrder.setIsBizCallback(YesNoEnum.NO.getCode()); payOrder.setPayNo(dto.getOutTradeNo()); + LocalDateTime expireTime = LocalDateTime.now().plusMinutes(15); + payOrder.setExpireTime(expireTime); payOrder.setMchId(dto.getMchId()); payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting)); try { @@ -114,7 +129,7 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService SettleInfo settleInfo = new SettleInfo(); settleInfo.setProfitSharing(false); // 是否需要分账 - if (dto.getAmount() > 10 && ObjectUtil.isNotEmpty(setting) && ObjectUtil.isNotEmpty(setting.getFzRatio()) && setting.getFzRatio() > 0) { + if (ObjectUtil.isNotEmpty(setting.getFzRatio()) && setting.getFzRatio() > 0) { settleInfo.setProfitSharing(true); payOrder.setEnableFz(YesNoEnum.YES.getCode()); } else { @@ -125,29 +140,78 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService request.setAmount(amount); request.setSpAppid(wechatConfig.getAppPlatformAppId()); request.setSpMchid(wechatConfig.getMerchantId()); - request.setSubMchid(dto.getSubMchid()); + request.setSubMchid(wechatPaySetting.getSubMchid()); request.setDescription(description); request.setSettleInfo(settleInfo); request.setOutTradeNo(dto.getOutTradeNo()); + request.setTimeExpire(formatWechatTime(expireTime)); request.setNotifyUrl(wechatConfig.getBaseCallbackUrl() + wechatConfig.getPayCallbackUrl()); payOrder.setPayRep(JSONUtil.toJsonStr(request)); + if (paymentOrderReservationService != null) { + paymentOrderReservationService.reserveAppPay(payOrder); + } PrepayWithRequestPaymentResponse res = appServiceExtension.prepayWithRequestPayment(request, wechatConfig.getAppPlatformAppId()); if (ObjectUtil.isEmpty(res)) { log.error("APP支付,res为空"); return null; } - payOrder.setPayTime(LocalDateTime.now()); payOrder.setPayRes(JSONUtil.toJsonStr(res)); return BeanUtil.beanToMap(res); } catch (Exception e) { log.error("|{}|APP支付,异常", TAG, e); return null; } finally { - thirdPayOrderService.save(payOrder); + if (paymentOrderReservationService != null) { + paymentOrderReservationService.finishAppPayCreate(payOrder); + } else { + // 仅供不启动 Spring 容器的既有单元测试;生产环境始终使用独立预留事务。 + thirdPayOrderService.save(payOrder); + if (paymentTaskEnqueuer != null) { + paymentTaskEnqueuer.enqueuePayQuery(payOrder.getTenantId(), payOrder.getId(), payOrder.getPayNo()); + } + } } } + /** + * 在创建支付订单前校验微信二级商户及平台抽佣参数,避免支付成功后才发现无法分账。 + * + * @param dto 待创建的 App 支付请求 + * @param setting 服务端查询到的商户支付配置 + * @return 已校验且包含微信子商户号的配置 + */ + private MchPaySettingEntity.WechatPaySetting validateWechatAppPaySetting(ThirdAppPlusPayDto dto, + MchPaySettingVo setting) { + if (dto == null || dto.getAmount() == null || dto.getAmount() <= 0) { + throw new RuntimeException("微信支付金额必须大于0"); + } + if (setting == null || StrUtil.isBlank(setting.getSettingJson())) { + throw new RuntimeException("商户微信支付配置不完整"); + } + MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean( + setting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class); + if (wechatPaySetting == null || StrUtil.isBlank(wechatPaySetting.getSubMchid())) { + throw new RuntimeException("微信支付子商户号未配置"); + } + if (setting.getFzRatio() == null || setting.getFzRatio() <= 0) { + return wechatPaySetting; + } + if (setting.getFzRatio() > 100) { + throw new RuntimeException("微信平台抽佣比例必须大于0且不超过100%"); + } + if (dto.getAmount() <= 10) { + throw new RuntimeException("微信分账支付金额必须大于10分"); + } + long fzAmount = BigDecimal.valueOf(dto.getAmount()) + .multiply(BigDecimal.valueOf(setting.getFzRatio())) + .divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN).longValueExact(); + if (fzAmount < 1L || fzAmount > dto.getAmount()) { + throw new RuntimeException("微信平台抽佣金额必须不少于1分且不能超过订单金额"); + } + return wechatPaySetting; + } + /** * 获取openId */ @@ -206,6 +270,36 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService } } + /** + * 使用支付订单配置快照中的子商户号关闭微信服务商 App 订单。 + * + *

SDK 只有在微信返回成功状态码时才会正常返回;支付已成功、网络异常或未知错误均返回 + * {@code false},由下一轮任务重新查单,禁止提前关闭本地订单。

+ * + * @param payNo 第三方支付服务生成的商户订单号 + * @param settingJson 创建订单时保存的子商户支付配置快照 + * @return SDK 无异常完成关单时返回 {@code true} + */ + @Override + public boolean close(String payNo, String settingJson) { + try { + MchPaySettingEntity.WechatPaySetting setting = parseWechatPaySettingSnapshot(settingJson); + CloseOrderRequest request = new CloseOrderRequest(); + request.setOutTradeNo(payNo); + request.setSpMchid(appPlusPayConfig.getWechatConfig().getMerchantId()); + request.setSubMchid(setting.getSubMchid()); + appServiceExtension.closeOrder(request); + return true; + } catch (ServiceException exception) { + log.warn("|{}|关闭微信App订单失败,payNo:{},errorCode:{}", TAG, payNo, + StrUtil.blankToDefault(exception.getErrorCode(), "UNKNOWN")); + } catch (Exception exception) { + log.error("|{}|关闭微信App订单异常,payNo:{},exceptionType:{}", TAG, payNo, + exception.getClass().getSimpleName()); + } + return false; + } + /** * 撤销订单 */ @@ -221,7 +315,6 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService * APP申请退款 */ @Override - @Transactional public ThirdPayOrderEntity refund(ThirdPayOrderEntity thirdPayOrder, ThirdMiniWechatRefundDto dto) { AppPlusPayConfig.WechatConfig wechatConfig = appPlusPayConfig.getWechatConfig(); @@ -229,11 +322,11 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService throw new RuntimeException("支付配置不存在"); } - // 订单退款前的回调 - boolean isFzRefund = appPlusWechatFzLifecycle.beforeOrderRefund(thirdPayOrder); - MchPaySettingEntity wechatPaySettingEntity = JSONUtil.toBean(thirdPayOrder.getMchPaySettingJson(), MchPaySettingEntity.class); MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean(wechatPaySettingEntity.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class); + if (wechatPaySetting == null || StrUtil.isBlank(wechatPaySetting.getSubMchid())) { + throw new RuntimeException("微信支付配置快照无效"); + } ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity(); updateObj.setId(thirdPayOrder.getId()); @@ -242,10 +335,16 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService if (ObjectUtil.isEmpty(refundFee)) { refundFee = thirdPayOrder.getPrice(); } - // 显式金额来自上游已核定的部分退款申请,不能被历史分账整单退款规则覆盖。 - if (ObjectUtil.isEmpty(dto.getRefundPrice()) && !isFzRefund && ObjectUtil.isNotEmpty(thirdPayOrder.getFzPrice())) { + // 已确认分账成功的订单使用稳定退分账单继续补偿;未成功分账的历史订单沿用原净额退款规则。 + boolean needFzRefund = ObjectUtil.equals(thirdPayOrder.getIsFzSuccess(), YesNoEnum.YES.getCode()) + && ObjectUtil.isNotEmpty(thirdPayOrder.getFzPrice()) && thirdPayOrder.getFzPrice() > 0; + if (ObjectUtil.isEmpty(dto.getRefundPrice()) && !needFzRefund && ObjectUtil.isNotEmpty(thirdPayOrder.getFzPrice())) { refundFee = thirdPayOrder.getPrice() - thirdPayOrder.getFzPrice(); } + if (refundFee == null || refundFee <= 0 || thirdPayOrder.getPrice() == null + || refundFee > thirdPayOrder.getPrice()) { + throw new RuntimeException("微信退款金额必须大于0且不能超过订单金额"); + } String refundNo = dto.getAfterSaleId() == null ? "refund_" + thirdPayOrder.getPayNo() : "refund_" + dto.getAfterSaleId(); @@ -267,28 +366,157 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService updateObj.setRefundNo(refundNo); updateObj.setRefundTime(LocalDateTime.now()); updateObj.setStatus(ThirdPayOrderStatusEnum.REFUNDING.getType()); + updateObj.setIsBizCallback(YesNoEnum.NO.getCode()); + boolean refundStarted = thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, thirdPayOrder.getId()) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())); + if (!refundStarted) { + ThirdPayOrderEntity latest = thirdPayOrderService.getById(thirdPayOrder.getId()); + if (latest != null && ObjectUtil.equals(latest.getRefundNo(), refundNo) + && (ObjectUtil.equals(latest.getStatus(), ThirdPayOrderStatusEnum.REFUNDING.getType()) + || ObjectUtil.equals(latest.getStatus(), ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType()))) { + return latest; + } + throw new RuntimeException("支付订单当前状态不允许退款"); + } + + // 先持久化普通退款请求,再使用稳定分账退款号发起退分账;两条链路均可由数据库轮询恢复。 + if (needFzRefund) { + appPlusWechatFzLifecycle.beforeOrderRefund(thirdPayOrderService.getById(thirdPayOrder.getId())); + } try { Refund refund = refundService.create(createRequest); - updateObj.setRefundRes(JSONUtil.toJsonStr(refund)); if (ObjectUtil.isEmpty(refund)) { throw new RuntimeException("微信退款申请错误异常"); } + saveWechatRefundResponse(thirdPayOrder.getId(), refundNo, refund); } catch (Exception e) { log.error("|{}|AP申请退款,异常", TAG, e); - throw new RuntimeException(e.getMessage()); + throw new RuntimeException("微信退款请求已进入补偿队列,请稍后查询结果"); } finally { - thirdPayOrderService.updateById(updateObj); + // 退款订单已先原子推进到 REFUNDING,渠道异常也必须留下稳定退款号对应的查单任务。 + if (paymentTaskEnqueuer != null) { + paymentTaskEnqueuer.enqueueRefundQuery(thirdPayOrder.getTenantId(), thirdPayOrder.getId(), + thirdPayOrder.getPayNo(), refundNo); + } } return thirdPayOrderService.getByPayNo(thirdPayOrder.getPayNo()); } + /** + * 保存微信退款同步响应并仅在渠道给出明确终态时推进本地状态,PROCESSING 保持退款中。 + * + * @param payOrderId 支付订单主键 + * @param refundNo 本次退款的稳定商户退款单号 + * @param refund 微信退款创建接口同步响应 + */ + private void saveWechatRefundResponse(Long payOrderId, String refundNo, Refund refund) { + ThirdPayOrderEntity update = new ThirdPayOrderEntity(); + update.setRefundRes(JSONUtil.toJsonStr(refund)); + if (ObjectUtil.equals(refund.getStatus(), Status.SUCCESS)) { + update.setStatus(ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType()); + } else if (ObjectUtil.equals(refund.getStatus(), Status.CLOSED) + || ObjectUtil.equals(refund.getStatus(), Status.ABNORMAL)) { + update.setStatus(ThirdPayOrderStatusEnum.REFUND_FAILED.getType()); + update.setIsBizCallback(YesNoEnum.YES.getCode()); + } + thirdPayOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, payOrderId) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.REFUNDING.getType()) + .eq(ThirdPayOrderEntity::getRefundNo, refundNo)); + } + /** * 查询退款 */ @Override public ThirdMiniWechatRefundQueryVo refundQuery(String payNo) { - AppPlusPayConfig.WechatConfig wechatConfig = appPlusPayConfig.getWechatConfig(); - return null; + ThirdPayOrderEntity payOrder = thirdPayOrderService.getByPayNo(payNo); + if (payOrder == null || StrUtil.isBlank(payOrder.getRefundNo()) + || StrUtil.isBlank(payOrder.getMchPaySettingJson())) { + return null; + } + try { + MchPaySettingEntity setting = JSONUtil.toBean(payOrder.getMchPaySettingJson(), MchPaySettingEntity.class); + MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean( + setting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class); + QueryByOutRefundNoRequest request = new QueryByOutRefundNoRequest(); + request.setOutRefundNo(payOrder.getRefundNo()); + request.setSubMchid(wechatPaySetting.getSubMchid()); + Refund refund = refundService.queryByOutRefundNo(request); + if (refund == null) { + return null; + } + ThirdMiniWechatRefundQueryVo result = new ThirdMiniWechatRefundQueryVo(); + result.setOut_trade_no(refund.getOutTradeNo()); + result.setRefund_status(refund.getStatus() == null ? null : refund.getStatus().name()); + result.setTransaction_id(refund.getTransactionId()); + result.setRefund_fee(refund.getAmount() == null ? null : refund.getAmount().getRefund()); + result.setTotal_fee(refund.getAmount() == null ? null : refund.getAmount().getTotal()); + return result; + } catch (Exception exception) { + log.warn("|{}|查询微信退款异常,payNo:{},exceptionType:{}", TAG, payNo, exception.getClass().getSimpleName()); + return retryWechatRefund(payOrder); + } + } + + /** + * 微信退款查单异常或渠道未找到请求时,使用支付订单保存的相同退款号和相同参数幂等重提。 + * + * @param payOrder 退款中的支付订单及创建请求快照 + * @return 渠道同步返回的退款状态;无法安全重提时返回 {@code null} + */ + private ThirdMiniWechatRefundQueryVo retryWechatRefund(ThirdPayOrderEntity payOrder) { + if (payOrder == null || StrUtil.isBlank(payOrder.getRefundRep())) { + return null; + } + try { + CreateRequest request = JSONUtil.toBean(payOrder.getRefundRep(), CreateRequest.class); + if (request == null || ObjectUtil.notEqual(request.getOutRefundNo(), payOrder.getRefundNo())) { + return null; + } + Refund refund = refundService.create(request); + if (refund == null) { + return null; + } + ThirdPayOrderEntity update = new ThirdPayOrderEntity(); + update.setRefundRes(JSONUtil.toJsonStr(refund)); + thirdPayOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, payOrder.getId()) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.REFUNDING.getType()) + .eq(ThirdPayOrderEntity::getRefundNo, payOrder.getRefundNo())); + return toRefundQueryVo(refund); + } catch (Exception exception) { + log.warn("|{}|幂等重提微信退款异常,payNo:{},exceptionType:{}", TAG, payOrder.getPayNo(), + exception.getClass().getSimpleName()); + return null; + } + } + + /** + * 将微信退款对象转换为现有跨层查询结果,统一回调与轮询使用的状态编码。 + * + * @param refund 微信退款创建或查询响应 + * @return 现有退款查询 VO + */ + private ThirdMiniWechatRefundQueryVo toRefundQueryVo(Refund refund) { + ThirdMiniWechatRefundQueryVo result = new ThirdMiniWechatRefundQueryVo(); + result.setOut_trade_no(refund.getOutTradeNo()); + result.setRefund_status(refund.getStatus() == null ? null : refund.getStatus().name()); + result.setTransaction_id(refund.getTransactionId()); + result.setRefund_fee(refund.getAmount() == null ? null : refund.getAmount().getRefund()); + result.setTotal_fee(refund.getAmount() == null ? null : refund.getAmount().getTotal()); + return result; + } + + /** + * 将本地支付截止时间转换为微信支付要求的带时区 RFC3339 字符串。 + * + * @param expireTime 本地支付订单截止时间 + * @return 精确到秒的微信 {@code time_expire} 参数 + */ + private String formatWechatTime(LocalDateTime expireTime) { + return expireTime.withNano(0).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); } /** @@ -338,6 +566,7 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService } updateObj.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); updateObj.setPayTime(LocalDateTime.now()); + updateObj.setIsBizCallback(aggregateSession == null ? YesNoEnum.NO.getCode() : null); boolean stateChanged = thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper() .eq(ThirdPayOrderEntity::getId, payOrderEntity.getId()) .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType())); @@ -513,22 +742,19 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService } /** - * 异步延迟触发现有微信分账生命周期,只在支付状态首次推进成功时调度一次。 + * 支付成功后写入延迟到期的分账创建任务;实际触发只由 Quartz 完成。 * * @param payOrderId 首次确认支付成功的内部订单主键 */ private void schedulePaySuccessLifecycle(Long payOrderId) { - CompletableFuture.runAsync(() -> { - try { - Thread.sleep(30000); - appPlusWechatFzLifecycle.orderPaySuccess(thirdPayOrderService.getById(payOrderId)); - } catch (InterruptedException exception) { - Thread.currentThread().interrupt(); - log.error("|{}|延迟调用orderPaySuccess被中断", TAG, exception); - } catch (Exception exception) { - log.error("|{}|延迟调用orderPaySuccess异常", TAG, exception); - } - }, executorService); + ThirdPayOrderEntity order = thirdPayOrderService.getById(payOrderId); + if (order == null || paymentTaskEnqueuer == null || order.getPayTime() == null) { + return; + } + paymentTaskEnqueuer.enqueueAt(order.getTenantId(), + com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum.FZ_CREATE, + "FZ_CREATE:" + order.getPayNo(), order.getId(), order.getPayNo(), null, null, null, + order.getPayTime().plusSeconds(30), 90); } /** @@ -582,17 +808,30 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService log.warn("|{}|refundCallback,退款状态或请求号不匹配,outRefundNo:|{}|", TAG, refundNotification.getOutRefundNo()); return payOrderEntity; } - // 更新订单状态 - boolean refundSuccess = ObjectUtil.equals(refundNotification.getRefundStatus(), Status.SUCCESS); + Status refundStatus = refundNotification.getRefundStatus(); + if (ObjectUtil.equals(refundStatus, Status.PROCESSING)) { + return payOrderEntity; + } + boolean refundSuccess = ObjectUtil.equals(refundStatus, Status.SUCCESS); + boolean refundFailed = ObjectUtil.equals(refundStatus, Status.CLOSED) + || ObjectUtil.equals(refundStatus, Status.ABNORMAL); + if (!refundSuccess && !refundFailed) { + log.warn("|{}|refundCallback,退款状态不可处理,outRefundNo:|{}|,status:{}", TAG, + refundNotification.getOutRefundNo(), refundStatus); + return payOrderEntity; + } ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity(); - updateObj.setId(payOrderEntity.getId()); updateObj.setRefundCallbackRes(JSONUtil.toJsonStr(refundNotification)); updateObj.setStatus(refundSuccess ? ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType() : ThirdPayOrderStatusEnum.REFUND_FAILED.getType()); - thirdPayOrderService.updateById(updateObj); + if (refundFailed) { + updateObj.setIsBizCallback(YesNoEnum.YES.getCode()); + } + thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, payOrderEntity.getId()) + .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.REFUNDING.getType()) + .eq(ThirdPayOrderEntity::getRefundNo, refundNotification.getOutRefundNo())); ThirdPayOrderEntity result = thirdPayOrderService.getById(payOrderEntity.getId()); - // 订单退款后 - appPlusWechatFzLifecycle.afterOrderRefund(result); return result; } @@ -601,11 +840,11 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService */ @Override public RefundNotification paseRefundCallback(String body, Map headers) { - String serialNumber = headers.get("Wechatpay-Serial"); - String nonce = headers.get("Wechatpay-Nonce"); - String signature = headers.get("Wechatpay-Signature"); - String signatureType = headers.get("Wechatpay-Signature-Type"); - String timestamp = headers.get("Wechatpay-Timestamp"); + String serialNumber = getHeaderIgnoreCase(headers, "Wechatpay-Serial"); + String nonce = getHeaderIgnoreCase(headers, "Wechatpay-Nonce"); + String signature = getHeaderIgnoreCase(headers, "Wechatpay-Signature"); + String signatureType = getHeaderIgnoreCase(headers, "Wechatpay-Signature-Type"); + String timestamp = getHeaderIgnoreCase(headers, "Wechatpay-Timestamp"); if (!ObjectUtil.isAllNotEmpty(serialNumber, nonce, signature, timestamp)) { return null; } @@ -620,6 +859,7 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService try { return notificationParser.parse(requestParam, RefundNotification.class); } catch (Exception e) { + log.warn("|{}|微信退款回调验签或解密异常,exceptionType:{}", TAG, e.getClass().getSimpleName()); return null; } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/fz/ThirdAppPlusWechatFzServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/fz/ThirdAppPlusWechatFzServiceImpl.java index 5b950b76..59e102d9 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/fz/ThirdAppPlusWechatFzServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/fz/ThirdAppPlusWechatFzServiceImpl.java @@ -1,7 +1,10 @@ package com.bsd.pjcloud.thirdparty.service.impl.appplus.fz; import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; +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; @@ -13,12 +16,14 @@ import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayOrderMapper; 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.wallet.api.entity.mch.MchPaySettingEntity; import com.wechat.pay.java.service.profitsharing.ProfitsharingService; import com.wechat.pay.java.service.profitsharing.model.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.dao.DuplicateKeyException; import org.springframework.validation.annotation.Validated; import java.math.BigDecimal; @@ -46,6 +51,11 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ private final ThirdPayOrderMapper thirdPayOrderMapper; private final ThirdFzOrderMapper thirdFzOrderMapper; + /** + * 支付任务写入器;退分账进入处理中后立即写入稳定查单任务,Quartz 仍是唯一周期触发入口。 + */ + private final ThirdPaymentTaskEnqueuer paymentTaskEnqueuer; + /** * 发起分账 */ @@ -74,14 +84,16 @@ 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() <= 0) { - log.info("|{}|订单分账错误,分账比例为0,订单号:{}", TAG, orderEntity.getPayNo()); - return null; + if (mchPaySetting.getFzRatio() == null || mchPaySetting.getFzRatio() <= 0 + || mchPaySetting.getFzRatio() > 100) { + throw new RuntimeException("微信平台抽佣比例必须大于0且不超过100%"); } String outOrderNo = "fz" + orderEntity.getPayNo(); ThirdFzOrderEntity fzOrderEntity = new ThirdFzOrderEntity(); + // 与支付订单共用业务唯一主键,避免多实例同时补偿时重复创建分账记录。 + fzOrderEntity.setId(orderEntity.getId()); fzOrderEntity.setPayNo(orderEntity.getPayNo()); fzOrderEntity.setFzNo(outOrderNo); fzOrderEntity.setFzTime(LocalDateTime.now()); @@ -91,23 +103,14 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ // 最少分账1分 Long amount = BigDecimal.valueOf(orderEntity.getPrice()) - .multiply(BigDecimal.valueOf(mchPaySetting.getFzRatio())).divide(BigDecimal.valueOf(100), 2, RoundingMode.DOWN) - .longValue(); - // 确保至少1分 - if (amount < 1L) { - amount = 1L; + .multiply(BigDecimal.valueOf(mchPaySetting.getFzRatio())) + .divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN).longValueExact(); + if (amount < 1L || amount > orderEntity.getPrice()) { + throw new RuntimeException("微信平台抽佣金额必须不少于1分且不能超过订单金额"); } ThirdPayOrderEntity thirdPayOrder = new ThirdPayOrderEntity(); - Integer fzPrice = 0; - if (ObjectUtil.isNotEmpty(orderEntity.getFzPrice())) { - fzPrice = orderEntity.getFzPrice(); - } - fzPrice += BigDecimal.valueOf(amount).intValue(); - thirdPayOrder.setId(orderEntity.getId()); - thirdPayOrder.setFzPrice(0); - thirdPayOrder.setIsFzSuccess(YesNoEnum.YES.getCode()); // 请求参数 CreateOrderRequest createDto = new CreateOrderRequest(); @@ -123,23 +126,43 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ zaxReceiver.setDescription(getDescription(mchPaySetting, amount)); createDto.setReceivers(List.of(zaxReceiver)); createDto.setUnfreezeUnsplit(true); + fzOrderEntity.setPrice(BigDecimal.valueOf(amount).intValue()); + fzOrderEntity.setCreateReq(JSONUtil.toJsonStr(createDto)); + fzOrderEntity.setCreateRes(buildFzSnapshot(null, createDto, null, null)); + try { + thirdFzOrderMapper.insert(fzOrderEntity); + } catch (DuplicateKeyException duplicateKeyException) { + // 多实例只允许一个实例持久化恢复记录;后续外部重提统一由相同分账号保证幂等。 + log.info("|{}|分账记录已由其他实例创建,payNo:{}", TAG, orderEntity.getPayNo()); + return null; + } + try { - fzOrderEntity.setPrice(BigDecimal.valueOf(amount).intValue()); - fzOrderEntity.setCreateReq(JSONUtil.toJsonStr(createDto)); OrdersEntity orderRes = profitsharingService.createOrder(createDto); - fzOrderEntity.setCreateRes(JSONUtil.toJsonStr(orderRes)); - fzOrderEntity.setStatus(ThirdFzOrderStatusEnum.FZ_SUCCESS.getType()); - thirdPayOrder.setFzPrice(fzPrice); + ThirdFzOrderEntity resultUpdate = new ThirdFzOrderEntity(); + resultUpdate.setCreateRes(buildFzSnapshot(fzOrderEntity.getCreateRes(), createDto, orderRes, null)); + if (orderRes != null && ObjectUtil.equals(orderRes.getState(), OrderStatus.FINISHED)) { + resultUpdate.setStatus(ThirdFzOrderStatusEnum.FZ_SUCCESS.getType()); + thirdPayOrder.setFzPrice(BigDecimal.valueOf(amount).intValue()); + thirdPayOrder.setIsFzSuccess(YesNoEnum.YES.getCode()); + } + boolean changed = fzOrderService.update(resultUpdate, new LambdaUpdateWrapper() + .eq(ThirdFzOrderEntity::getId, fzOrderEntity.getId()) + .eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_UNPAID.getType())); + if (changed && ObjectUtil.equals(resultUpdate.getStatus(), ThirdFzOrderStatusEnum.FZ_SUCCESS.getType())) { + thirdPayOrderMapper.updateById(thirdPayOrder); + } return orderRes; } catch (Exception e) { - e.printStackTrace(); - fzOrderEntity.setCreateError(e.getMessage()); - fzOrderEntity.setStatus(ThirdFzOrderStatusEnum.FZ_FAILED.getType()); + ThirdFzOrderEntity errorUpdate = new ThirdFzOrderEntity(); + errorUpdate.setCreateError(e.getClass().getSimpleName()); + errorUpdate.setCreateRes(buildFzSnapshot(fzOrderEntity.getCreateRes(), createDto, null, + e.getClass().getSimpleName())); + fzOrderService.update(errorUpdate, new LambdaUpdateWrapper() + .eq(ThirdFzOrderEntity::getId, fzOrderEntity.getId()) + .eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_UNPAID.getType())); log.error("|{}|发起分账意外的错误,error:|{}|", TAG, e.getMessage()); return null; - } finally { - thirdFzOrderMapper.insert(fzOrderEntity); - thirdPayOrderMapper.updateById(thirdPayOrder); } } @@ -162,38 +185,52 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ String outOrderNo = "refund_" + fzOrderDetail.getFzNo(); + CreateReturnOrderRequest returnDto = new CreateReturnOrderRequest(); + returnDto.setSubMchid(wechatPaySetting.getSubMchid()); + returnDto.setOutOrderNo(fzOrderDetail.getFzNo()); + returnDto.setOutReturnNo(outOrderNo); + returnDto.setReturnMchid(wechatConfig.getMerchantId()); + returnDto.setAmount((long) fzOrderDetail.getPrice()); + returnDto.setDescription("退回" + getDescription(mchPaySetting, returnDto.getAmount())); + ThirdFzOrderEntity updateObj = new ThirdFzOrderEntity(); - updateObj.setId(fzOrderDetail.getId()); updateObj.setFzRefundTime(LocalDateTime.now()); updateObj.setStatus(ThirdFzOrderStatusEnum.FZ_REFUNDING.getType()); updateObj.setFzRefundNo(outOrderNo); + updateObj.setFzRefundReq(JSONUtil.toJsonStr(returnDto)); + updateObj.setFzRefundRes(buildFzRefundSnapshot(null, returnDto, null, null)); + boolean refundStarted = fzOrderService.update(updateObj, new LambdaUpdateWrapper() + .eq(ThirdFzOrderEntity::getId, fzOrderDetail.getId()) + .eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_SUCCESS.getType())); + if (!refundStarted) { + return null; + } + // 先持久化恢复事实再调用微信,进程即使在外部请求期间退出,Quartz 也能沿用原退分账号继续查询或重提。 + paymentTaskEnqueuer.enqueueFzRefundQuery(orderEntity.getTenantId(), orderEntity.getId(), + fzOrderDetail.getId(), fzOrderDetail.getPayNo(), fzOrderDetail.getFzNo(), outOrderNo); try { - CreateReturnOrderRequest returnDto = new CreateReturnOrderRequest(); - returnDto.setSubMchid(wechatPaySetting.getSubMchid()); - returnDto.setOutOrderNo(fzOrderDetail.getFzNo()); - returnDto.setOutReturnNo(outOrderNo); - returnDto.setReturnMchid(wechatConfig.getMerchantId()); - returnDto.setAmount((long) fzOrderDetail.getPrice()); - returnDto.setDescription("退回" + getDescription(mchPaySetting, returnDto.getAmount())); - - updateObj.setFzRefundReq(JSONUtil.toJsonStr(returnDto)); ReturnOrdersEntity returnOrderRes = profitsharingService.createReturnOrder(returnDto); - updateObj.setFzRefundRes(JSONUtil.toJsonStr(returnOrderRes)); + ThirdFzOrderEntity responseUpdate = new ThirdFzOrderEntity(); + responseUpdate.setFzRefundRes(buildFzRefundSnapshot(null, returnDto, returnOrderRes, null)); if (ObjectUtil.equals(returnOrderRes.getResult(), ReturnOrderStatus.SUCCESS)) { - updateObj.setStatus(ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType()); + responseUpdate.setStatus(ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType()); } else if (ObjectUtil.equals(returnOrderRes.getResult(), ReturnOrderStatus.FAILED)) { - updateObj.setStatus(ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType()); + responseUpdate.setStatus(ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType()); } + fzOrderService.update(responseUpdate, new LambdaUpdateWrapper() + .eq(ThirdFzOrderEntity::getId, fzOrderDetail.getId()) + .eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_REFUNDING.getType())); return returnOrderRes; } catch (Exception e) { - e.printStackTrace(); - updateObj.setFzRefundError(e.getMessage()); - updateObj.setStatus(ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType()); + ThirdFzOrderEntity errorUpdate = new ThirdFzOrderEntity(); + errorUpdate.setFzRefundError(e.getClass().getSimpleName()); + errorUpdate.setFzRefundRes(buildFzRefundSnapshot(null, returnDto, null, e.getClass().getSimpleName())); + fzOrderService.update(errorUpdate, new LambdaUpdateWrapper() + .eq(ThirdFzOrderEntity::getId, fzOrderDetail.getId()) + .eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_REFUNDING.getType())); log.error("|{}|分账退款意外的错误,error:|{}|", TAG, e.getMessage()); return null; - } finally { - thirdFzOrderMapper.updateById(updateObj); } } @@ -272,6 +309,225 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ } } + /** + * 查询微信分账状态,只有微信明确返回 FINISHED 才更新支付订单分账结果。 + * + * @param fzOrder 待确认的微信分账订单 + */ + @Override + public void pollFzOrder(ThirdFzOrderEntity fzOrder) { + if (fzOrder == null || ObjectUtil.notEqual(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_UNPAID.getType())) { + return; + } + OrdersEntity result = getFzOrder(fzOrder); + if (result == null) { + result = retryCreateOrder(fzOrder); + if (result == null) { + return; + } + } + ThirdFzOrderEntity update = new ThirdFzOrderEntity(); + update.setCreateRes(buildFzSnapshot(fzOrder.getCreateRes(), null, result, null)); + if (!ObjectUtil.equals(result.getState(), OrderStatus.FINISHED)) { + fzOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdFzOrderEntity::getId, fzOrder.getId()) + .eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_UNPAID.getType())); + return; + } + update.setStatus(ThirdFzOrderStatusEnum.FZ_SUCCESS.getType()); + boolean changed = fzOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdFzOrderEntity::getId, fzOrder.getId()) + .eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_UNPAID.getType())); + if (!changed) { + return; + } + ThirdPayOrderEntity payOrder = thirdPayOrderService.getByPayNo(fzOrder.getPayNo()); + if (payOrder == null) { + return; + } + ThirdPayOrderEntity payUpdate = new ThirdPayOrderEntity(); + payUpdate.setFzPrice(fzOrder.getPrice()); + payUpdate.setIsFzSuccess(YesNoEnum.YES.getCode()); + thirdPayOrderService.update(payUpdate, new LambdaUpdateWrapper() + .eq(ThirdPayOrderEntity::getId, payOrder.getId()) + .and(wrapper -> wrapper.isNull(ThirdPayOrderEntity::getIsFzSuccess) + .or().ne(ThirdPayOrderEntity::getIsFzSuccess, YesNoEnum.YES.getCode()))); + } + + /** + * 查询微信分账退款状态,处理中记录保持原状态供后续轮询恢复。 + * + * @param fzOrder 退款中的微信分账订单 + */ + @Override + public void pollFzRefundOrder(ThirdFzOrderEntity fzOrder) { + if (fzOrder == null || ObjectUtil.notEqual(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_REFUNDING.getType())) { + return; + } + ReturnOrdersEntity result = getFzRefundOrder(fzOrder); + if (result == null) { + result = retryCreateReturnOrder(fzOrder); + if (result == null) { + return; + } + } + ThirdFzOrderEntity update = new ThirdFzOrderEntity(); + update.setFzRefundRes(buildFzRefundSnapshot(fzOrder.getFzRefundRes(), null, result, null)); + if (ObjectUtil.equals(result.getResult(), ReturnOrderStatus.SUCCESS)) { + update.setStatus(ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType()); + } else if (ObjectUtil.equals(result.getResult(), ReturnOrderStatus.FAILED)) { + update.setStatus(ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType()); + } + fzOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdFzOrderEntity::getId, fzOrder.getId()) + .eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_REFUNDING.getType())); + } + + /** + * 微信分账查询失败时使用已保存的原始请求和相同分账单号幂等重提。 + * + * @param fzOrder 待确认分账订单 + * @return 微信同步响应;无法恢复请求时返回 {@code null} + */ + private OrdersEntity retryCreateOrder(ThirdFzOrderEntity fzOrder) { + if (fzOrder == null || StrUtil.isBlank(fzOrder.getCreateReq())) { + return null; + } + try { + CreateOrderRequest request = JSONUtil.toBean(fzOrder.getCreateReq(), CreateOrderRequest.class); + if (request == null || ObjectUtil.notEqual(request.getOutOrderNo(), fzOrder.getFzNo())) { + return null; + } + return profitsharingService.createOrder(request); + } catch (Exception exception) { + ThirdFzOrderEntity update = new ThirdFzOrderEntity(); + update.setCreateError(exception.getClass().getSimpleName()); + update.setCreateRes(buildFzSnapshot(fzOrder.getCreateRes(), null, null, + exception.getClass().getSimpleName())); + fzOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdFzOrderEntity::getId, fzOrder.getId()) + .eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_UNPAID.getType())); + return null; + } + } + + /** + * 微信退分账查询失败时使用已保存的原始请求和相同退分账单号幂等重提。 + * + * @param fzOrder 退分账中的分账订单 + * @return 微信同步响应;无法恢复请求时返回 {@code null} + */ + private ReturnOrdersEntity retryCreateReturnOrder(ThirdFzOrderEntity fzOrder) { + if (fzOrder == null || StrUtil.isBlank(fzOrder.getFzRefundReq())) { + return null; + } + try { + CreateReturnOrderRequest request = JSONUtil.toBean(fzOrder.getFzRefundReq(), CreateReturnOrderRequest.class); + if (request == null || ObjectUtil.notEqual(request.getOutReturnNo(), fzOrder.getFzRefundNo())) { + return null; + } + return profitsharingService.createReturnOrder(request); + } catch (Exception exception) { + ThirdFzOrderEntity update = new ThirdFzOrderEntity(); + update.setFzRefundError(exception.getClass().getSimpleName()); + update.setFzRefundRes(buildFzRefundSnapshot(fzOrder.getFzRefundRes(), null, null, + exception.getClass().getSimpleName())); + fzOrderService.update(update, new LambdaUpdateWrapper() + .eq(ThirdFzOrderEntity::getId, fzOrder.getId()) + .eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_REFUNDING.getType())); + return null; + } + } + + /** + * 合并微信平台抽佣阶段快照,保留首次请求并持续更新最近查询时间、外部状态和错误。 + * + * @param existingJson 已保存的结构化快照,允许为空 + * @param request 首次创建请求,后续查询允许为空 + * @param response 本轮微信响应,允许为空 + * @param error 本轮错误类别,允许为空 + * @return 可保存到 create_res 的结构化 JSON + */ + private String buildFzSnapshot(String existingJson, CreateOrderRequest request, OrdersEntity response, String error) { + JSONObject root = parseSnapshot(existingJson); + root.set("provider", "WECHAT"); + JSONObject royalty = root.getJSONObject("royalty"); + if (royalty == null) { + royalty = JSONUtil.createObj(); + } + if (request != null) { + royalty.set("request", JSONUtil.parseObj(JSONUtil.toJsonStr(request))); + } + if (response != null) { + royalty.set("response", JSONUtil.parseObj(JSONUtil.toJsonStr(response))); + royalty.set("externalStatus", response.getState() == null ? null : response.getState().name()); + royalty.set("status", ObjectUtil.equals(response.getState(), OrderStatus.FINISHED) ? "SUCCESS" : "PROCESSING"); + } else if (error != null) { + royalty.set("status", "PROCESSING"); + } else if (request != null && StrUtil.isBlank(royalty.getStr("status"))) { + royalty.set("status", "PROCESSING"); + } + royalty.set("error", error); + royalty.set("lastQueryTime", LocalDateTime.now().toString()); + root.set("royalty", royalty); + return root.toString(); + } + + /** + * 合并微信退分账阶段快照,处理中响应保持可恢复状态。 + * + * @param existingJson 已保存的结构化快照,允许为空 + * @param request 首次退分账请求,后续查询允许为空 + * @param response 本轮微信响应,允许为空 + * @param error 本轮错误类别,允许为空 + * @return 可保存到 fz_refund_res 的结构化 JSON + */ + private String buildFzRefundSnapshot(String existingJson, CreateReturnOrderRequest request, + ReturnOrdersEntity response, String error) { + JSONObject root = parseSnapshot(existingJson); + root.set("provider", "WECHAT"); + JSONObject refund = root.getJSONObject("refund"); + if (refund == null) { + refund = JSONUtil.createObj(); + } + if (request != null) { + refund.set("request", JSONUtil.parseObj(JSONUtil.toJsonStr(request))); + } + if (response != null) { + refund.set("response", JSONUtil.parseObj(JSONUtil.toJsonStr(response))); + refund.set("externalStatus", response.getResult() == null ? null : response.getResult().name()); + if (ObjectUtil.equals(response.getResult(), ReturnOrderStatus.SUCCESS)) { + refund.set("status", "SUCCESS"); + } else if (ObjectUtil.equals(response.getResult(), ReturnOrderStatus.FAILED)) { + refund.set("status", "FAILED"); + } else { + refund.set("status", "PROCESSING"); + } + } else if (error != null) { + refund.set("status", "PROCESSING"); + } else if (request != null && StrUtil.isBlank(refund.getStr("status"))) { + refund.set("status", "PROCESSING"); + } + refund.set("error", error); + refund.set("lastQueryTime", LocalDateTime.now().toString()); + root.set("refund", refund); + return root.toString(); + } + + /** + * 安全解析历史或新结构快照;历史非 JSON 内容按空对象兼容。 + * + * @param json 已保存的 JSON 字符串 + * @return 可继续写入的 JSON 对象 + */ + private JSONObject parseSnapshot(String json) { + try { + return StrUtil.isBlank(json) ? JSONUtil.createObj() : JSONUtil.parseObj(json); + } catch (Exception exception) { + return JSONUtil.createObj(); + } + } + /** * 添加分账方 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/fz/lifecycle/ThirdAppPlusWechatFzLifecycle.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/fz/lifecycle/ThirdAppPlusWechatFzLifecycle.java index 790b812a..db7db670 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/fz/lifecycle/ThirdAppPlusWechatFzLifecycle.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/fz/lifecycle/ThirdAppPlusWechatFzLifecycle.java @@ -1,8 +1,11 @@ package com.bsd.pjcloud.thirdparty.service.impl.appplus.fz.lifecycle; import cn.hutool.core.util.ObjectUtil; +import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatFzService; +import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService; import com.bsd.pjcloud.thirdparty.service.impl.appplus.common.AppPlusOrderLifecycle; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -14,6 +17,11 @@ public class ThirdAppPlusWechatFzLifecycle implements AppPlusOrderLifecycle { private final ThirdAppPlusWechatFzService appPlusWechatFzService; + /** + * 分账订单服务,用于把已经进入退分账中的稳定请求识别为可恢复流程。 + */ + private final ThirdFzOrderService thirdFzOrderService; + /** * 订单创建前 * @@ -51,8 +59,21 @@ public class ThirdAppPlusWechatFzLifecycle implements AppPlusOrderLifecycle { */ @Override public boolean beforeOrderRefund(ThirdPayOrderEntity order) { - // 分账退款 - return ObjectUtil.isNotEmpty(appPlusWechatFzService.refund(order)); + ThirdFzOrderEntity fzOrder = thirdFzOrderService.getByPayNo(order.getPayNo()); + if (fzOrder == null) { + return false; + } + if (ObjectUtil.equals(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_REFUNDING.getType()) + || ObjectUtil.equals(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType())) { + return true; + } + if (ObjectUtil.notEqual(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_SUCCESS.getType())) { + return false; + } + appPlusWechatFzService.refund(order); + ThirdFzOrderEntity latest = thirdFzOrderService.getById(fzOrder.getId()); + return latest != null && (ObjectUtil.equals(latest.getStatus(), ThirdFzOrderStatusEnum.FZ_REFUNDING.getType()) + || ObjectUtil.equals(latest.getStatus(), ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType())); } /** diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java index 9e39989a..959672cc 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java @@ -155,8 +155,8 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl过期只允许从活动状态写入;支付成功或平台关闭还允许覆盖过期状态,用于接收关单补偿期间 - * 查询到的真实平台终态,但不会覆盖已经确认的成功或关闭结果。

+ *

过期只允许从活动状态写入;平台关闭允许覆盖过期状态。支付成功还允许覆盖本地已关闭状态, + * 仅用于恢复已经验签并完成关键字段校验的延迟成功回调,确保真实扣款事实优先于本地超时判断。

* * @param sessionId 聚合会话主键 * @param status 目标终态 @@ -173,16 +173,23 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl sourceStatuses = ObjectUtil.equals(status, ThirdAggregatePaySessionStatusEnum.EXPIRED) - ? List.of( - ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(), - ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(), - ThirdAggregatePaySessionStatusEnum.PAYING.getType()) - : List.of( - ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(), - ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(), - ThirdAggregatePaySessionStatusEnum.PAYING.getType(), - ThirdAggregatePaySessionStatusEnum.EXPIRED.getType()); + List sourceStatuses; + if (ObjectUtil.equals(status, ThirdAggregatePaySessionStatusEnum.EXPIRED)) { + sourceStatuses = List.of(ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(), + ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(), + ThirdAggregatePaySessionStatusEnum.PAYING.getType()); + } else if (ObjectUtil.equals(status, ThirdAggregatePaySessionStatusEnum.PAY_SUCCESS)) { + sourceStatuses = List.of(ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(), + ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(), + ThirdAggregatePaySessionStatusEnum.PAYING.getType(), + ThirdAggregatePaySessionStatusEnum.EXPIRED.getType(), + ThirdAggregatePaySessionStatusEnum.PAY_CLOSED.getType()); + } else { + sourceStatuses = List.of(ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(), + ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(), + ThirdAggregatePaySessionStatusEnum.PAYING.getType(), + ThirdAggregatePaySessionStatusEnum.EXPIRED.getType()); + } return update(update, new LambdaUpdateWrapper() .eq(ThirdAggregatePaySessionEntity::getId, sessionId) .in(ThirdAggregatePaySessionEntity::getStatus, sourceStatuses)); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java new file mode 100644 index 00000000..af5442ce --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java @@ -0,0 +1,456 @@ +package com.bsd.pjcloud.thirdparty.service.impl.pay; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; +import com.alipay.api.AlipayClient; +import com.alipay.api.AlipayConfig; +import com.alipay.api.DefaultAlipayClient; +import com.alipay.api.domain.AlipayTradeRoyaltyRelationBatchqueryModel; +import com.alipay.api.domain.AlipayTradeRoyaltyRelationBindModel; +import com.alipay.api.domain.RoyaltyEntity; +import com.alipay.api.request.AlipayTradeRoyaltyRelationBatchqueryRequest; +import com.alipay.api.request.AlipayTradeRoyaltyRelationBindRequest; +import com.alipay.api.response.AlipayTradeRoyaltyRelationBatchqueryResponse; +import com.alipay.api.response.AlipayTradeRoyaltyRelationBindResponse; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationSceneEnum; +import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationStatusEnum; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdMchFzRelationMapper; +import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService; +import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import com.github.yulichang.toolkit.MPJReflectionKit; +import com.wechat.pay.java.service.profitsharing.ProfitsharingService; +import com.wechat.pay.java.service.profitsharing.model.AddReceiverRequest; +import com.wechat.pay.java.service.profitsharing.model.AddReceiverResponse; +import com.wechat.pay.java.service.profitsharing.model.ReceiverRelationType; +import com.wechat.pay.java.service.profitsharing.model.ReceiverType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * 商户微信/支付宝默认平台分佣关系实现。 + * + *

关系绑定是支付前置条件,但外部渠道调用由 Quartz 的 + * {@code FZ_RELATION_BIND} 任务执行;本服务只负责关系事实物化、渠道请求和结果快照。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService { + + /** 微信支付平台持久化编码。 */ + private static final String WECHAT_TYPE = AppPlusPlatformPayType.WECHAT.getType(); + /** 支付宝支付平台持久化编码。 */ + private static final String ALIPAY_TYPE = AppPlusPlatformPayType.ALI_PAY.getType(); + /** 关系绑定的固定业务场景。 */ + private static final String DEFAULT_SCENE = ThirdMchFzRelationSceneEnum.DEFAULT_PLATFORM_COMMISSION.getCode(); + /** 本地关系绑定任务的优先级,需早于支付创建后的分账任务。 */ + private static final int RELATION_TASK_PRIORITY = 150; + + /** 关系事实 Mapper。 */ + private final ThirdMchFzRelationMapper relationMapper; + /** 关系绑定任务写入器。 */ + private final ThirdPaymentTaskEnqueuer taskEnqueuer; + /** 微信服务商分账 SDK。 */ + private final ProfitsharingService profitsharingService; + /** 应用级支付宝、微信平台配置。 */ + private final AppPlusPayConfig appPlusPayConfig; + + /** + * 物化当前商户启用且配置抽佣比例的默认关系,并把旧配置标记为历史关系。 + * + * @param merchant 商户内部快照 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void syncDefaultRelations(MchManageVo merchant) { + if (merchant == null || merchant.getId() == null) { + return; + } + Long tenantId = merchant.getTenantId() == null ? TenantContextHolder.getTenantId() : merchant.getTenantId(); + if (tenantId == null) { + throw new IllegalStateException("商户分账关系租户不能为空"); + } + List settings = merchant.getPaySettingList() == null + ? List.of() : merchant.getPaySettingList(); + List currentSettingIds = new ArrayList<>(); + for (MchPaySettingVo setting : settings) { + if (!isEligible(setting)) { + continue; + } + currentSettingIds.add(setting.getId()); + ThirdMchFzRelationEntity relation = buildRelation(tenantId, merchant, setting); + ThirdMchFzRelationEntity existing = relationMapper.selectOne(new LambdaQueryWrapper() + .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) + .eq(ThirdMchFzRelationEntity::getPaySettingId, setting.getId()) + .eq(ThirdMchFzRelationEntity::getPlatformType, relation.getPlatformType()) + .eq(ThirdMchFzRelationEntity::getChannelAppId, relation.getChannelAppId()) + .eq(ThirdMchFzRelationEntity::getChannelSubMchId, relation.getChannelSubMchId()) + .eq(ThirdMchFzRelationEntity::getReceiverType, relation.getReceiverType()) + .eq(ThirdMchFzRelationEntity::getReceiverAccount, relation.getReceiverAccount()) + .eq(ThirdMchFzRelationEntity::getRelationScene, DEFAULT_SCENE) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0")); + if (existing == null) { + relationMapper.insert(relation); + existing = relation; + } + relationMapper.update(null, new LambdaUpdateWrapper() + .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) + .eq(ThirdMchFzRelationEntity::getPaySettingId, setting.getId()) + .eq(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode()) + .ne(ThirdMchFzRelationEntity::getId, existing.getId()) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0") + .set(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.NO.getCode()) + .set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode())); + if (!ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(existing.getStatus()) + && !ThirdMchFzRelationStatusEnum.BINDING.getCode().equals(existing.getStatus())) { + taskEnqueuer.enqueueAt(tenantId, com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum.FZ_RELATION_BIND, + "FZ_RELATION_BIND:" + existing.getId(), null, null, null, null, null, + LocalDateTime.now(), RELATION_TASK_PRIORITY); + } + } + // 配置被禁用、删除或改成其他平台账号时,旧关系只停止接新单,不删除渠道事实。 + LambdaUpdateWrapper stale = new LambdaUpdateWrapper() + .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) + .eq(ThirdMchFzRelationEntity::getMchId, merchant.getId()) + .eq(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode()) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0"); + if (!currentSettingIds.isEmpty()) { + stale.notIn(ThirdMchFzRelationEntity::getPaySettingId, currentSettingIds); + } + stale.set(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.NO.getCode()) + .set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode()); + relationMapper.update(null, stale); + } + + /** + * 校验支付配置当前默认关系已明确绑定。 + * + * @param setting 商户支付配置 + * @return 已绑定关系;无抽佣比例时为空 + */ + @Override + public ThirdMchFzRelationEntity requireBound(MchPaySettingVo setting) { + if (!isEligible(setting)) { + return null; + } + Long tenantId = setting.getTenantId() == null ? TenantContextHolder.getTenantId() : setting.getTenantId(); + ThirdMchFzRelationEntity expected = buildRelation(tenantId, setting.getMchId(), setting); + ThirdMchFzRelationEntity relation = relationMapper.selectOne(new LambdaQueryWrapper() + .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) + .eq(ThirdMchFzRelationEntity::getMchId, setting.getMchId()) + .eq(ThirdMchFzRelationEntity::getPaySettingId, setting.getId()) + .eq(ThirdMchFzRelationEntity::getPlatformType, expected.getPlatformType()) + .eq(ThirdMchFzRelationEntity::getChannelAppId, expected.getChannelAppId()) + .eq(ThirdMchFzRelationEntity::getChannelSubMchId, expected.getChannelSubMchId()) + .eq(ThirdMchFzRelationEntity::getReceiverType, expected.getReceiverType()) + .eq(ThirdMchFzRelationEntity::getReceiverAccount, expected.getReceiverAccount()) + .eq(ThirdMchFzRelationEntity::getRelationScene, DEFAULT_SCENE) + .eq(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode()) + .eq(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BOUND.getCode()) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0")); + if (relation == null) { + throw new IllegalStateException("支付平台分佣关系尚未绑定,请稍后重试"); + } + return relation; + } + + /** + * 执行关系绑定并保存脱敏快照;异常或渠道未明确成功时返回失败,交由任务状态机重试。 + * + * @param relationId 关系主键 + * @return 关系已绑定 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean bind(Long relationId) { + ThirdMchFzRelationEntity relation = relationMapper.selectById(relationId); + if (relation == null || !"0".equals(relation.getDelFlag())) { + return false; + } + if (ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(relation.getStatus())) { + return true; + } + relationMapper.update(null, new LambdaUpdateWrapper() + .eq(ThirdMchFzRelationEntity::getId, relationId) + .in(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode(), + ThirdMchFzRelationStatusEnum.BIND_FAILED.getCode()) + .set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BINDING) + .set(ThirdMchFzRelationEntity::getLastQueryTime, LocalDateTime.now())); + relation = relationMapper.selectById(relationId); + if (relation == null || !ThirdMchFzRelationStatusEnum.BINDING.getCode().equals(relation.getStatus())) { + return ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(relation == null ? null : relation.getStatus()); + } + try { + if (WECHAT_TYPE.equals(relation.getPlatformType())) { + return bindWechat(relation); + } + if (ALIPAY_TYPE.equals(relation.getPlatformType())) { + return bindAlipay(relation); + } + return markFailed(relation, "UNSUPPORTED_PLATFORM", "不支持的支付平台"); + } catch (Exception exception) { + log.warn("绑定商户平台分佣关系异常,relationId:{},type:{},exceptionType:{}", relationId, + relation.getPlatformType(), exception.getClass().getSimpleName()); + return markFailed(relation, "NETWORK_OR_SYSTEM", exception.getMessage()); + } + } + + /** 构建关系事实。 */ + private ThirdMchFzRelationEntity buildRelation(Long tenantId, MchManageVo merchant, MchPaySettingVo setting) { + ThirdMchFzRelationEntity result = buildRelation(tenantId, setting.getMchId(), setting); + result.setReceiverName("浙江资安信信息科技有限公司"); + return result; + } + + /** 按支付配置解析渠道关系身份和平台默认接收方。 */ + private ThirdMchFzRelationEntity buildRelation(Long tenantId, Long mchId, MchPaySettingVo setting) { + if (tenantId == null || mchId == null || setting == null || setting.getId() == null) { + throw new IllegalArgumentException("商户支付配置关系参数不完整"); + } + ThirdMchFzRelationEntity result = new ThirdMchFzRelationEntity(); + result.setId(IdWorker.getId()); + result.setTenantId(tenantId); + result.setMchId(mchId); + result.setPaySettingId(setting.getId()); + result.setPlatformType(setting.getPlatformType()); + result.setRelationScene(DEFAULT_SCENE); + result.setIsDefault(YesNoEnum.YES.getCode()); + result.setStatus(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode()); + result.setDelFlag("0"); + result.setCreateTime(LocalDateTime.now()); + result.setUpdateTime(result.getCreateTime()); + if (WECHAT_TYPE.equals(setting.getPlatformType())) { + AppPlusPayConfig.WechatConfig config = requireWechatConfig(); + MchPaySettingEntity.WechatPaySetting paySetting = parseWechat(setting); + result.setChannelAppId(config.getAppPlatformAppId()); + result.setChannelSubMchId(paySetting.getSubMchid()); + result.setReceiverType(ReceiverType.MERCHANT_ID.name()); + result.setReceiverAccount(config.getMerchantId()); + result.setChannelRelationType(ReceiverRelationType.SERVICE_PROVIDER.name()); + result.setOutRequestNo("wxrel_" + setting.getId() + "_" + paySetting.getSubMchid()); + result.setBindReq(JSONUtil.createObj().set("platform", "WECHAT").set("appid", config.getAppPlatformAppId()) + .set("subMchid", paySetting.getSubMchid()).set("receiverType", ReceiverType.MERCHANT_ID.name()) + .set("receiverAccount", mask(paySetting(config.getMerchantId()))).toString()); + } else if (ALIPAY_TYPE.equals(setting.getPlatformType())) { + AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(setting); + MchPaySettingEntity.AliPaySetting paySetting = parseAliPay(setting); + String receiverType = StrUtil.blankToDefault(config.getRoyaltyTransInType(), "userId"); + String receiverAccount = StrUtil.blankToDefault(config.getRoyaltyTransIn(), ""); + if (StrUtil.isBlank(receiverAccount)) { + throw new IllegalStateException("平台支付宝分佣接收账号未配置"); + } + if (!List.of("userId", "loginName").contains(receiverType)) { + throw new IllegalStateException("支付宝分佣接收方类型只支持userId或loginName"); + } + result.setChannelAppId(config.getAppId()); + result.setChannelSubMchId(paySetting.getSubMchid()); + result.setAccountKey(paySetting.getAccountKey()); + result.setReceiverType(receiverType); + result.setReceiverAccount(receiverAccount); + result.setReceiverName(config.getRoyaltyTransInName()); + result.setOutRequestNo("alirel_" + setting.getId() + "_" + paySetting.getSubMchid()); + result.setBindReq(JSONUtil.createObj().set("platform", "ALIPAY").set("appId", config.getAppId()) + .set("smid", paySetting.getSubMchid()).set("receiverType", receiverType) + .set("receiverAccount", mask(receiverAccount)).set("appAuthTokenConfigured", StrUtil.isNotBlank(paySetting.getAppAuthToken())).toString()); + } else { + throw new IllegalArgumentException("不支持的支付平台关系"); + } + return result; + } + + /** 判断支付配置是否需要平台分佣关系。 */ + private boolean isEligible(MchPaySettingVo setting) { + return setting != null && setting.getId() != null && YesNoEnum.YES.getCode().equals(setting.getEnable()) + && List.of(WECHAT_TYPE, ALIPAY_TYPE).contains(setting.getPlatformType()) + && setting.getFzRatio() != null && setting.getFzRatio() > 0 && setting.getFzRatio() <= 100; + } + + /** 微信关系绑定。 */ + private boolean bindWechat(ThirdMchFzRelationEntity relation) { + AddReceiverRequest request = new AddReceiverRequest(); + request.setAppid(relation.getChannelAppId()); + request.setSubMchid(relation.getChannelSubMchId()); + request.setType(ReceiverType.MERCHANT_ID); + request.setAccount(relation.getReceiverAccount()); + request.setName(StrUtil.blankToDefault(relation.getReceiverName(), "浙江资安信信息科技有限公司")); + request.setRelationType(ReceiverRelationType.SERVICE_PROVIDER); + AddReceiverResponse response = profitsharingService.addReceiver(request); + if (response == null) { + return markFailed(relation, "EMPTY_RESPONSE", "微信未返回关系绑定结果"); + } + return markBound(relation, JSONUtil.toJsonStr(response), response.getAccount()); + } + + /** 支付宝关系绑定。 */ + private boolean bindAlipay(ThirdMchFzRelationEntity relation) throws Exception { + MchPaySettingEntity.AliPaySetting paySetting = new MchPaySettingEntity.AliPaySetting(); + paySetting.setAccountKey(relation.getAccountKey()); + AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(paySetting); + AlipayTradeRoyaltyRelationBindModel model = new AlipayTradeRoyaltyRelationBindModel(); + model.setOutRequestNo(relation.getOutRequestNo()); + RoyaltyEntity receiver = new RoyaltyEntity(); + receiver.setType(relation.getReceiverType()); + if ("loginName".equals(relation.getReceiverType())) { + receiver.setLoginName(relation.getReceiverAccount()); + } else { + receiver.setAccount(relation.getReceiverAccount()); + } + receiver.setName(relation.getReceiverName()); + receiver.setMemo("平台默认分佣关系"); + model.setReceiverList(List.of(receiver)); + AlipayTradeRoyaltyRelationBindRequest request = new AlipayTradeRoyaltyRelationBindRequest(); + request.setBizModel(model); + if (StrUtil.isNotBlank(paySetting.getAppAuthToken())) { + request.putOtherTextParam("app_auth_token", paySetting.getAppAuthToken()); + } + AlipayTradeRoyaltyRelationBindResponse response = client(config).execute(request); + if (response != null && response.isSuccess()) { + return markBound(relation, JSONUtil.toJsonStr(response), response.getResultCode()); + } + // 绑定响应丢失时用相同 out_request_no 查询,避免重复建立关系或误判失败。 + AlipayTradeRoyaltyRelationBatchqueryResponse queried = queryAlipay(config, relation.getOutRequestNo(), paySetting.getAppAuthToken()); + if (queried != null && queried.isSuccess() && queried.getReceiverList() != null && !queried.getReceiverList().isEmpty()) { + return markBound(relation, JSONUtil.toJsonStr(queried), "QUERY_BOUND"); + } + return markFailed(relation, response == null ? "EMPTY_RESPONSE" : response.getSubCode(), + response == null ? "支付宝未返回关系绑定结果" : response.getSubMsg()); + } + + /** 查询支付宝关系绑定结果。 */ + private AlipayTradeRoyaltyRelationBatchqueryResponse queryAlipay(AppPlusPayConfig.AliPayConfig config, + String outRequestNo, String appAuthToken) throws Exception { + AlipayTradeRoyaltyRelationBatchqueryModel model = new AlipayTradeRoyaltyRelationBatchqueryModel(); + model.setOutRequestNo(outRequestNo); + model.setPageNum(1L); + model.setPageSize(20L); + AlipayTradeRoyaltyRelationBatchqueryRequest request = new AlipayTradeRoyaltyRelationBatchqueryRequest(); + request.setBizModel(model); + if (StrUtil.isNotBlank(appAuthToken)) { + request.putOtherTextParam("app_auth_token", appAuthToken); + } + return client(config).execute(request); + } + + /** 动态创建支付宝客户端,命名账号不存在时直接失败。 */ + private AlipayClient client(AppPlusPayConfig.AliPayConfig config) { + if (config == null || StrUtil.hasBlank(config.getAppId(), config.getAppPrivateKey(), config.getAlipayPublicKey())) { + throw new IllegalStateException("支付宝平台账号配置不完整"); + } + AlipayConfig sdk = new AlipayConfig(); + sdk.setServerUrl("https://openapi.alipay.com/gateway.do"); + sdk.setAppId(config.getAppId()); + sdk.setPrivateKey(config.getAppPrivateKey()); + sdk.setFormat("json"); + sdk.setAlipayPublicKey(config.getAlipayPublicKey()); + sdk.setCharset("UTF-8"); + sdk.setSignType("RSA2"); + return new DefaultAlipayClient(sdk); + } + + /** 解析微信支付配置。 */ + private MchPaySettingEntity.WechatPaySetting parseWechat(MchPaySettingVo setting) { + MchPaySettingEntity.WechatPaySetting result = StrUtil.isBlank(setting.getSettingJson()) ? null + : JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class); + if (result == null || StrUtil.isBlank(result.getSubMchid())) { + throw new IllegalStateException("微信子商户号未配置"); + } + return result; + } + + /** 解析支付宝直付通配置。 */ + private MchPaySettingEntity.AliPaySetting parseAliPay(MchPaySettingVo setting) { + MchPaySettingEntity.AliPaySetting result = StrUtil.isBlank(setting.getSettingJson()) ? null + : JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + if (result == null || StrUtil.isBlank(result.getSubMchid())) { + throw new IllegalStateException("支付宝直付通二级商户标识未配置"); + } + return result; + } + + /** 读取微信平台配置。 */ + private AppPlusPayConfig.WechatConfig requireWechatConfig() { + AppPlusPayConfig.WechatConfig config = appPlusPayConfig.getWechatConfig(); + if (config == null || StrUtil.hasBlank(config.getAppPlatformAppId(), config.getMerchantId())) { + throw new IllegalStateException("微信平台分佣配置不完整"); + } + return config; + } + + /** 按 accountKey 读取支付宝平台配置。 */ + private AppPlusPayConfig.AliPayConfig resolveAliPayConfig(MchPaySettingVo setting) { + MchPaySettingEntity.AliPaySetting paySetting = parseAliPay(setting); + return resolveAliPayConfig(paySetting); + } + + /** 按 accountKey 读取支付宝平台配置。 */ + private AppPlusPayConfig.AliPayConfig resolveAliPayConfig(MchPaySettingEntity.AliPaySetting setting) { + AppPlusPayConfig.AliPayConfig base = appPlusPayConfig.getAliPayConfig(); + if (base == null) { + throw new IllegalStateException("支付宝平台配置不存在"); + } + if (StrUtil.isBlank(setting == null ? null : setting.getAccountKey())) { + return base; + } + AppPlusPayConfig.AliPayConfig named = base.getAccounts() == null ? null : base.getAccounts().get(setting.getAccountKey()); + if (named == null) { + throw new IllegalStateException("支付宝平台账号不存在"); + } + return named; + } + + /** 保存绑定成功快照。 */ + private boolean markBound(ThirdMchFzRelationEntity relation, String response, String externalStatus) { + return relationMapper.update(null, new LambdaUpdateWrapper() + .eq(ThirdMchFzRelationEntity::getId, relation.getId()) + .eq(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BINDING.getCode()) + .set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BOUND.getCode()) + .set(ThirdMchFzRelationEntity::getBindRes, response) + .set(ThirdMchFzRelationEntity::getBindError, null) + .set(ThirdMchFzRelationEntity::getLastExternalStatus, externalStatus) + .set(ThirdMchFzRelationEntity::getLastQueryTime, LocalDateTime.now()) + .set(ThirdMchFzRelationEntity::getBoundTime, LocalDateTime.now())) > 0; + } + + /** 保存确定性失败或可重试失败快照。 */ + private boolean markFailed(ThirdMchFzRelationEntity relation, String code, String message) { + relationMapper.update(null, new LambdaUpdateWrapper() + .eq(ThirdMchFzRelationEntity::getId, relation.getId()) + .eq(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BINDING.getCode()) + .set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BIND_FAILED.getCode()) + .set(ThirdMchFzRelationEntity::getLastExternalStatus, code) + .set(ThirdMchFzRelationEntity::getBindError, StrUtil.subPre(StrUtil.blankToDefault(message, code), 1000)) + .set(ThirdMchFzRelationEntity::getLastQueryTime, LocalDateTime.now())); + return false; + } + + /** 脱敏账号。 */ + private String mask(String value) { + if (StrUtil.isBlank(value) || value.length() <= 4) { + return "****"; + } + return value.substring(0, 2) + "****" + value.substring(value.length() - 2); + } + + /** 避免静态检查把敏感值直接写入快照。 */ + private String paySetting(String value) { + return value; + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImpl.java index 504d83df..f06f0f31 100755 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImpl.java @@ -1,26 +1,42 @@ package com.bsd.pjcloud.thirdparty.service.impl.pay; import cn.hutool.core.collection.IterUtil; +import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.ObjectUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; 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.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum; +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.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.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; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; import java.util.List; import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; /** * 第三方支付订单 @@ -31,20 +47,58 @@ import java.util.Map; @RequiredArgsConstructor public class ThirdPayOrderServiceImpl extends ServiceImpl implements ThirdPayOrderService { + /** + * 用户远程服务,用于批量补充管理端列表中的支付用户信息。 + */ private final RemoteUserService remoteUserService; + /** + * 商户远程服务,用于按订单保存的可信商户 ID 批量补充商户名称。 + */ + private final RemoteMchService remoteMchService; + + /** + * 分账订单服务,用于把已有 JSON 快照投影为管理端阶段状态。 + */ + private final ThirdFzOrderService thirdFzOrderService; + + /** 支付任务 Mapper;可选注入保持历史管理端测试构造器兼容。 */ + @Autowired(required = false) + private ThirdPaymentTaskMapper thirdPaymentTaskMapper; + + /** + * 按数据权限分页查询支付订单,并补充只用于管理端展示的关联信息。 + * + * @param page 分页参数 + * @param dto 支付订单筛选条件,包含可选商户 ID + * @param dataScope 当前用户的数据权限范围 + * @return 已脱敏并补充商户、用户和分账阶段信息的分页 + */ @Override public Page scopePage(Page page, ThirdPayOrderDto dto, DataScope dataScope) { Page pageList = baseMapper.scopePage(page, dto, dataScope); - disposePageList(pageList.getRecords(), dto); + disposePageList(pageList.getRecords()); return pageList; } + /** + * 按支付订单号查询内部支付订单。 + * + * @param payNo 平台侧稳定商户订单号 + * @return 唯一支付订单,不存在时为空 + */ @Override public ThirdPayOrderEntity getByPayNo(String payNo) { return getOne(new LambdaQueryWrapper().eq(ThirdPayOrderEntity::getPayNo, payNo)); } + /** + * 按业务类型和业务 ID 查询支付订单。 + * + * @param bizType 支付业务类型 + * @param bizId 业务系统订单 ID + * @return 对应支付订单,不存在时为空 + */ @Override public ThirdPayOrderEntity getByBiz(ThirdPayOrderBizTypeEnum bizType, String bizId) { return getOne(new LambdaQueryWrapper() @@ -53,22 +107,383 @@ public class ThirdPayOrderServiceImpl extends ServiceImpl list, ThirdPayOrderDto dto) { - if (ObjectUtil.isEmpty(list)) return; - // 查询用户 + /** + * 批量补充关联数据并在返回浏览器前清除包含授权令牌的商户支付配置快照。 + * + * @param list 当前分页支付订单 + */ + private void disposePageList(List list) { + if (ObjectUtil.isEmpty(list)) { + return; + } + fillUsers(list); + fillMerchants(list); + fillProfitSharingStages(list); + fillTaskStates(list); + list.forEach(order -> order.setMchPaySettingJson(null)); + } + + /** + * 批量加载支付用户,远程服务异常或缺失数据不阻断支付订单列表。 + * + * @param list 当前分页支付订单 + */ + private void fillUsers(List list) { List userIdList = list.stream().map(ThirdPayOrderEntity::getUserId).filter(ObjectUtil::isNotEmpty).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIdList)) { - List userList = remoteUserService.getInnerUserById(userIdList).getData(); + if (ObjectUtil.isEmpty(userIdList)) { + return; + } + try { + R> result = remoteUserService.getInnerUserById(userIdList); + List userList = result == null || !result.isOk() ? null : result.getData(); if (ObjectUtil.isNotEmpty(userList)) { Map userMap = IterUtil.toMap(userList, SysUser::getUserId); - for (ThirdPayOrderVo user : list) { - SysUser targetUser = userMap.get(user.getUserId()); - if (ObjectUtil.isEmpty(targetUser)) continue; - user.setUsername(targetUser.getName()); - user.setUserPhone(targetUser.getPhone()); + for (ThirdPayOrderVo order : list) { + SysUser targetUser = userMap.get(order.getUserId()); + if (ObjectUtil.isEmpty(targetUser)) { + continue; + } + order.setUsername(targetUser.getName()); + order.setUserPhone(targetUser.getPhone()); } } + } catch (Exception exception) { + log.warn("支付订单列表补充用户信息失败,errorType:{}", exception.getClass().getSimpleName()); } } -} \ No newline at end of file + /** + * 按可信商户 ID 批量补充商户名称,并在脱敏前提取支付宝直付通 smid。 + * + * @param list 当前分页支付订单 + */ + private void fillMerchants(List list) { + List mchIds = list.stream().map(ThirdPayOrderEntity::getMchId).filter(ObjectUtil::isNotEmpty).distinct().toList(); + Map merchantMap = Map.of(); + if (ObjectUtil.isNotEmpty(mchIds)) { + try { + R> result = remoteMchService.getByIds(mchIds); + List merchants = result == null || !result.isOk() ? null : result.getData(); + if (ObjectUtil.isNotEmpty(merchants)) { + merchantMap = merchants.stream().filter(item -> item != null && item.getId() != null) + .collect(Collectors.toMap(MchManageVo::getId, Function.identity(), (first, second) -> first)); + } + } catch (Exception exception) { + log.warn("支付订单列表补充商户信息失败,errorType:{}", exception.getClass().getSimpleName()); + } + } + for (ThirdPayOrderVo order : list) { + MchManageVo merchant = merchantMap.get(order.getMchId()); + if (merchant != null) { + order.setMchName(merchant.getName()); + } + order.setSmid(readAlipaySmid(order)); + } + } + + /** + * 从订单创建时的支付宝配置快照读取直付通二级商户标识,不读取当前可变配置。 + * + * @param order 支付订单视图 + * @return 支付宝 smid;非支付宝订单、历史快照缺失或格式错误时为空 + */ + private String readAlipaySmid(ThirdPayOrderVo order) { + if (!ObjectUtil.equals(order.getPayPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY.getType()) + || StrUtil.isBlank(order.getMchPaySettingJson())) { + 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(); + } catch (Exception exception) { + log.warn("支付订单支付宝商户快照解析失败,payNo:{},errorType:{}", order.getPayNo(), + exception.getClass().getSimpleName()); + return null; + } + } + + /** + * 批量加载分账订单并投影确认结算、平台抽佣与退分账三个独立阶段。 + * + * @param list 当前分页支付订单 + */ + private void fillProfitSharingStages(List list) { + List payNos = list.stream().map(ThirdPayOrderEntity::getPayNo).filter(StrUtil::isNotBlank).distinct().toList(); + Map fzOrderMap = Map.of(); + if (ObjectUtil.isNotEmpty(payNos)) { + List fzOrders = thirdFzOrderService.list(new LambdaQueryWrapper() + .in(ThirdFzOrderEntity::getPayNo, payNos)); + if (ObjectUtil.isNotEmpty(fzOrders)) { + fzOrderMap = fzOrders.stream().filter(item -> item != null && StrUtil.isNotBlank(item.getPayNo())) + .collect(Collectors.toMap(ThirdFzOrderEntity::getPayNo, Function.identity(), (first, second) -> first)); + } + } + for (ThirdPayOrderVo order : list) { + fillProfitSharingStage(order, fzOrderMap.get(order.getPayNo())); + } + } + + /** + * 批量投影当前支付查单、退款查单或分账任务状态,帮助运营区分未配置、处理中和失败。 + * @param list 当前支付订单分页 + */ + private void fillTaskStates(List list) { + if (thirdPaymentTaskMapper == null) { + return; + } + List payNos = list.stream().map(ThirdPayOrderEntity::getPayNo) + .filter(StrUtil::isNotBlank).distinct().toList(); + if (ObjectUtil.isEmpty(payNos)) { + return; + } + List tasks = thirdPaymentTaskMapper.selectList( + new LambdaQueryWrapper() + .in(com.bsd.pjcloud.thirdparty.entity.pay.ThirdPaymentTaskEntity::getPayNo, payNos) + .orderByDesc(com.bsd.pjcloud.thirdparty.entity.pay.ThirdPaymentTaskEntity::getUpdateTime)); + Map latest = tasks.stream() + .filter(task -> StrUtil.isNotBlank(task.getPayNo())) + .collect(Collectors.toMap(com.bsd.pjcloud.thirdparty.entity.pay.ThirdPaymentTaskEntity::getPayNo, + Function.identity(), (first, second) -> taskStatusPriority(first.getStatus()) + >= taskStatusPriority(second.getStatus()) ? first : second)); + for (ThirdPayOrderVo order : list) { + com.bsd.pjcloud.thirdparty.entity.pay.ThirdPaymentTaskEntity task = latest.get(order.getPayNo()); + if (task == null) { + continue; + } + order.setTaskId(task.getId()); + order.setTaskStatus(task.getStatus()); + order.setTaskNextRunTime(task.getNextRunTime()); + order.setTaskAttemptCount(task.getAttemptCount()); + order.setTaskLastError(task.getLastErrorMessage()); + } + } + + /** + * 返回支付任务在管理端的处置优先级,确保失败或告警任务不会被同订单较新的成功任务遮挡。 + * + *

同优先级任务依赖数据库按更新时间倒序的稳定输入,保留最先出现的最新任务。

+ * + * @param status {@code third_payment_task.status} 持久化编码 + * @return 数值越大越需要优先展示和人工处理 + */ + private int taskStatusPriority(String status) { + if ("ALERT".equals(status)) { + return 7; + } + if ("FAILED".equals(status)) { + return 6; + } + if ("PROCESSING".equals(status)) { + return 5; + } + if ("RETRY".equals(status)) { + return 4; + } + if ("READY".equals(status)) { + return 3; + } + if ("SUCCESS".equals(status)) { + return 2; + } + if ("CANCELLED".equals(status)) { + return 1; + } + return 0; + } + + /** + * 将一笔分账记录的结构化 JSON 快照转换为管理端阶段字段。 + * + * @param order 支付订单视图 + * @param fzOrder 对应分账订单;尚未创建时为空 + */ + private void fillProfitSharingStage(ThirdPayOrderVo order, ThirdFzOrderEntity fzOrder) { + boolean enabled = ObjectUtil.equals(order.getEnableFz(), YesNoEnum.YES.getCode()); + if (!enabled) { + order.setSettleConfirmStatus("NOT_CONFIGURED"); + order.setRoyaltyStatus("NOT_CONFIGURED"); + order.setFzRefundStatus("NOT_CONFIGURED"); + return; + } + boolean alipay = ObjectUtil.equals(order.getPayPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY.getType()); + order.setSettleConfirmStatus(alipay ? "PENDING" : "NOT_APPLICABLE"); + order.setRoyaltyStatus("PENDING"); + order.setFzRefundStatus("NOT_STARTED"); + if (fzOrder == null) { + return; + } + order.setFzStatus(fzOrder.getStatus()); + JSONObject createSnapshot = parseObject(fzOrder.getCreateRes()); + if (alipay) { + copySettleConfirmStage(order, createSnapshot == null ? null : createSnapshot.getJSONObject("settleConfirm")); + } + copyRoyaltyStage(order, createSnapshot == null ? null : createSnapshot.getJSONObject("royalty"), fzOrder); + copyRefundStage(order, fzOrder); + } + + /** + * 复制支付宝确认结算阶段;快照缺失时保留 PENDING,避免把抽佣成功前置阶段误当作最终分账。 + * + * @param order 支付订单视图 + * @param stage 确认结算阶段 JSON + */ + private void copySettleConfirmStage(ThirdPayOrderVo order, JSONObject stage) { + if (stage == null) { + return; + } + order.setSettleConfirmStatus(StrUtil.blankToDefault(stage.getStr("status"), "PROCESSING")); + order.setSettleConfirmReq(toJson(stage.get("request"))); + order.setSettleConfirmRes(toJson(stage.get("response"))); + order.setSettleConfirmError(toJson(stage.get("error"))); + } + + /** + * 复制平台抽佣阶段,并兼容只有 third_fz_order 状态、尚无结构化快照的历史记录。 + * + * @param order 支付订单视图 + * @param stage 平台抽佣阶段 JSON + * @param fzOrder 分账订单 + */ + private void copyRoyaltyStage(ThirdPayOrderVo order, JSONObject stage, ThirdFzOrderEntity fzOrder) { + if (stage != null) { + order.setRoyaltyStatus(StrUtil.blankToDefault(stage.getStr("status"), resolveRoyaltyStatus(fzOrder.getStatus()))); + order.setRoyaltyReq(toJson(stage.get("request"))); + JSONObject responses = JSONUtil.createObj(); + Object submitResponse = stage.get("submitResponse") == null ? stage.get("response") : stage.get("submitResponse"); + if (submitResponse != null) { + responses.set("submit", submitResponse); + } + if (stage.get("query") != null) { + responses.set("query", stage.get("query")); + } + order.setRoyaltyRes(responses.isEmpty() ? null : responses.toString()); + order.setRoyaltyError(toJson(stage.get("error"))); + return; + } + if (ObjectUtil.equals(order.getPayPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY.getType())) { + // 支付宝 create_req 在首阶段保存的是确认结算请求,抽佣尚未发起时不能把它投影到抽佣列。 + boolean royaltyPreviouslySucceeded = List.of(ThirdFzOrderStatusEnum.FZ_SUCCESS.getType(), + ThirdFzOrderStatusEnum.FZ_REFUNDING.getType(), + ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType(), + ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType()).contains(fzOrder.getStatus()); + order.setRoyaltyStatus(royaltyPreviouslySucceeded ? "SUCCESS" : "NOT_STARTED"); + return; + } + order.setRoyaltyStatus(resolveRoyaltyStatus(fzOrder.getStatus())); + order.setRoyaltyReq(normalizeJson(fzOrder.getCreateReq())); + order.setRoyaltyError(toJson(fzOrder.getCreateError())); + } + + /** + * 复制退分账阶段,并兼容支付宝查询快照、微信 refund 子对象及旧版原始响应。 + * + * @param order 支付订单视图 + * @param fzOrder 分账订单 + */ + private void copyRefundStage(ThirdPayOrderVo order, ThirdFzOrderEntity fzOrder) { + order.setFzRefundStatus(resolveRefundStatus(fzOrder.getStatus())); + JSONObject refundSnapshot = parseObject(fzOrder.getFzRefundRes()); + JSONObject stage = refundSnapshot == null ? null : refundSnapshot.getJSONObject("refund"); + if (stage != null) { + order.setFzRefundStatus(StrUtil.blankToDefault(stage.getStr("status"), order.getFzRefundStatus())); + order.setFzRefundReq(toJson(stage.get("request"))); + order.setFzRefundRes(toJson(stage.get("response"))); + order.setFzRefundError(toJson(stage.get("error"))); + } else if (refundSnapshot != null) { + order.setFzRefundReq(normalizeJson(fzOrder.getFzRefundReq())); + order.setFzRefundRes(toJson(refundSnapshot.get("response") == null ? refundSnapshot : refundSnapshot.get("response"))); + order.setFzRefundError(toJson(fzOrder.getFzRefundError())); + } else { + order.setFzRefundReq(normalizeJson(fzOrder.getFzRefundReq())); + order.setFzRefundRes(normalizeJson(fzOrder.getFzRefundRes())); + order.setFzRefundError(toJson(fzOrder.getFzRefundError())); + } + } + + /** + * 根据分账订单持久化状态推导平台抽佣阶段状态。 + * + * @param status third_fz_order.status 编码 + * @return 管理端通用阶段状态 + */ + private String resolveRoyaltyStatus(String status) { + if (ObjectUtil.equals(status, ThirdFzOrderStatusEnum.FZ_FAILED.getType())) { + return "FAILED"; + } + if (ObjectUtil.equals(status, ThirdFzOrderStatusEnum.FZ_SUCCESS.getType()) + || ObjectUtil.equals(status, ThirdFzOrderStatusEnum.FZ_REFUNDING.getType()) + || ObjectUtil.equals(status, ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType()) + || ObjectUtil.equals(status, ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType())) { + return "SUCCESS"; + } + return "PROCESSING"; + } + + /** + * 根据分账订单持久化状态推导退分账阶段状态。 + * + * @param status third_fz_order.status 编码 + * @return 管理端通用退分账状态 + */ + private String resolveRefundStatus(String status) { + if (ObjectUtil.equals(status, ThirdFzOrderStatusEnum.FZ_REFUNDING.getType())) { + return "PROCESSING"; + } + if (ObjectUtil.equals(status, ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType())) { + return "SUCCESS"; + } + if (ObjectUtil.equals(status, ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType())) { + return "FAILED"; + } + return "NOT_STARTED"; + } + + /** + * 安全解析 JSON 对象,历史非 JSON 文本按空值兼容。 + * + * @param value 数据库 JSON 快照 + * @return JSON 对象;空值或格式错误时为空 + */ + private JSONObject parseObject(String value) { + if (StrUtil.isBlank(value)) { + return null; + } + try { + return JSONUtil.parseObj(value); + } catch (Exception ignored) { + return null; + } + } + + /** + * 把阶段对象序列化成前端可安全解析的 JSON 字符串。 + * + * @param value 阶段请求、响应或错误对象 + * @return JSON 字符串;空值时为空 + */ + private String toJson(Object value) { + return value == null ? null : JSONUtil.toJsonStr(value); + } + + /** + * 兼容历史字段中的 JSON 或普通文本,确保管理端 JSON 预览组件不会解析报错。 + * + * @param value 历史请求、响应或错误文本 + * @return 标准 JSON 字符串;空值时为空 + */ + private String normalizeJson(String value) { + if (StrUtil.isBlank(value)) { + return null; + } + try { + return JSONUtil.toJsonStr(JSONUtil.parse(value)); + } catch (Exception ignored) { + return JSONUtil.toJsonStr(value); + } + } + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPaymentTaskServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPaymentTaskServiceImpl.java new file mode 100644 index 00000000..632ef3a0 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPaymentTaskServiceImpl.java @@ -0,0 +1,642 @@ +package com.bsd.pjcloud.thirdparty.service.impl.pay; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.*; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPaymentTaskDispatchDto; +import com.bsd.pjcloud.thirdparty.entity.pay.*; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskAttemptMapper; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskMapper; +import com.bsd.pjcloud.thirdparty.service.*; +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.stereotype.Service; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import java.net.InetAddress; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.*; + +/** + * 支付任务 Worker。Quartz 只负责调用 {@link #dispatch(ThirdPaymentTaskDispatchDto)}, + * 外部渠道调用在有界线程池中执行,数据库租约负责跨实例幂等和故障恢复。 + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class ThirdPaymentTaskServiceImpl extends ServiceImpl + implements ThirdPaymentTaskService { + + /** 支付任务执行日志 Mapper。 */ + private final ThirdPaymentTaskAttemptMapper attemptMapper; + /** 支付订单服务。 */ + private final ThirdPayOrderService payOrderService; + /** 分账订单服务。 */ + private final ThirdFzOrderService fzOrderService; + /** 支付查询与业务回调编排服务。 */ + private final ThirdAppPlusService appPlusService; + /** 微信分账服务。 */ + private final ThirdAppPlusWechatFzService wechatFzService; + /** 支付宝直付通服务。 */ + private final ThirdAppPlusAliPayService aliPayService; + /** 聚合会话服务。 */ + private final ThirdAggregatePaySessionService aggregateSessionService; + /** 聚合会话业务处理服务。 */ + private final ThirdAggregateOrderPayService aggregateOrderPayService; + /** 实时任务写入器,用于对账发现状态缺口后复用原业务号重新排队。 */ + private final ThirdPaymentTaskEnqueuer paymentTaskEnqueuer; + + /** Worker并发数,受配置限制,避免渠道调用耗尽 Web 线程。 */ + @Value("${third.payment.task.worker-threads:16}") + private int workerThreads; + /** Worker队列容量,满时本轮任务退回数据库而不是无限堆积。 */ + @Value("${third.payment.task.queue-capacity:256}") + private int queueCapacity; + /** 租约持续时间,必须覆盖单次渠道超时和状态落库。 */ + @Value("${third.payment.task.lease-seconds:120}") + private long leaseSeconds; + /** 最大退避秒数。 */ + @Value("${third.payment.task.max-backoff-seconds:900}") + private long maxBackoffSeconds; + /** 告警阈值,超过后标记 ALERT 但仍允许重试。 */ + @Value("${third.payment.task.alert-attempts:12}") + private int alertAttempts; + /** 当前 Worker唯一标识。 */ + private String workerId; + /** 有界执行队列。 */ + private ThreadPoolExecutor executor; + + /** + * 初始化有界 Worker;不创建任何定时器,任务只能由 Quartz 或实时路径提交。 + */ + @PostConstruct + public void startWorker() { + workerId = resolveWorkerId(); + executor = new ThreadPoolExecutor(workerThreads, workerThreads, 30, TimeUnit.SECONDS, + new ArrayBlockingQueue<>(queueCapacity), new ThreadPoolExecutor.AbortPolicy()); + } + + /** + * 关闭 Worker,等待已领取租约的任务由其他实例在租约到期后恢复。 + */ + @PreDestroy + public void stopWorker() { + if (executor != null) { + executor.shutdown(); + } + } + + /** + * Quartz 固定触发动作:恢复过期租约、物化业务任务、CAS领取后提交有界队列。 + * + * @param dto 任务类型、批量上限和触发来源;触发时间不作为 thirdparty 租约时钟 + * @return 本轮物化、成功提交、处理中、繁忙状态和到期积压统计 + */ + @Override + public ThirdPaymentTaskDispatchVo dispatch(ThirdPaymentTaskDispatchDto dto) { + ThirdPaymentTaskTypeEnum type = ThirdPaymentTaskTypeEnum.fromCode(dto.getTaskType()); + int batchSize = Math.max(1, Math.min(dto.getBatchSize() == null ? 100 : dto.getBatchSize(), 500)); + // Quartz 的 triggerTime 可能因为 misfire 或 Feign 排队而落后于当前时间,不能拿它作为租约起点; + // 租约和 CAS 必须使用 thirdparty 实例实际收到分发请求的时间,避免刚领取就被其他实例恢复。 + LocalDateTime now = LocalDateTime.now(); + baseMapper.recoverExpiredLeases(type.getCode(), now); + int accepted = materialize(type, now, batchSize); + List due = baseMapper.selectDueTasks(type.getCode(), now, batchSize); + int claimed = 0; + int processing = executor == null ? 0 : executor.getActiveCount() + executor.getQueue().size(); + boolean busy = executor == null || executor.getQueue().remainingCapacity() == 0; + for (ThirdPaymentTaskEntity candidate : due) { + String token = UUID.randomUUID().toString().replace("-", ""); + LocalDateTime expire = now.plusSeconds(leaseSeconds); + if (baseMapper.claim(candidate.getId(), now, workerId, token, expire) != 1) { + continue; + } + ThirdPaymentTaskEntity leased = candidate; + leased.setLeaseToken(token); + leased.setLeaseOwner(workerId); + leased.setLeaseExpireTime(expire); + leased.setAttemptCount((candidate.getAttemptCount() == null ? 0 : candidate.getAttemptCount()) + 1); + try { + if (executor == null || executor.getQueue().remainingCapacity() == 0) { + baseMapper.release(leased.getId(), token, now.plusSeconds(5), "Worker队列已满"); + busy = true; + continue; + } + executor.execute(() -> executeLeasedTask(leased, dto.getTriggerSource())); + claimed++; + } catch (RejectedExecutionException exception) { + baseMapper.release(leased.getId(), token, now.plusSeconds(5), "Worker拒绝任务"); + busy = true; + } + } + long backlog = baseMapper.countBacklog(type.getCode(), now); + return ThirdPaymentTaskDispatchVo.builder().acceptedCount(accepted).claimedCount(claimed) + .processingCount(processing).busy(busy).backlogCount(backlog).build(); + } + + /** + * 根据业务表当前事实幂等物化任务;插入冲突不视为错误。 + * + * @param type 本轮只处理的任务类型 + * @param now thirdparty 接收分发请求的时间,也是新任务默认可执行时间 + * @param limit 本轮最多读取的业务候选数量 + * @return 成功插入的新任务事实数量,已存在的稳定任务键不计入 + */ + private int materialize(ThirdPaymentTaskTypeEnum type, LocalDateTime now, int limit) { + List candidates = baseMapper.selectMaterializationCandidates(type.getCode(), now, limit); + int accepted = 0; + for (ThirdPaymentTaskEntity candidate : candidates) { + candidate.setId(IdWorker.getId()); + candidate.setStatus(ThirdPaymentTaskStatusEnum.READY.getCode()); + candidate.setAttemptCount(0); + candidate.setCreateTime(now); + candidate.setUpdateTime(now); + candidate.setDelFlag("0"); + if (baseMapper.insertIgnore(candidate) > 0) { + accepted++; + } + } + return accepted; + } + + /** + * 执行已取得租约的任务;离开本地队列时先续租,随后在任务所属租户上下文中完成数据访问和渠道调用。 + * + * @param task 已通过 CAS 领取并携带租约令牌的任务快照 + * @param triggerSource Quartz、人工或实时路径等触发来源,空值按 QUARTZ 记录 + */ + private void executeLeasedTask(ThirdPaymentTaskEntity task, String triggerSource) { + LocalDateTime executionStart = LocalDateTime.now(); + int renewed = baseMapper.renewLease(task.getId(), task.getLeaseToken(), workerId, + executionStart.plusSeconds(leaseSeconds), executionStart); + if (renewed != 1) { + // 队列等待期间租约可能已过期并被其他实例恢复;旧队列项不得再访问任何支付渠道。 + log.info("支付任务队列项租约已失效,跳过执行,taskId:{},workerId:{}", task.getId(), workerId); + return; + } + TenantBroker.runAs(task.getTenantId(), ignored -> { + ThirdPaymentTaskAttemptEntity attempt = new ThirdPaymentTaskAttemptEntity(); + attempt.setTaskId(task.getId()); + attempt.setTenantId(task.getTenantId()); + attempt.setAttemptNo(task.getAttemptCount() == null ? 1 : task.getAttemptCount()); + attempt.setTriggerSource(StrUtil.blankToDefault(triggerSource, "QUARTZ")); + attempt.setWorkerId(workerId); + attempt.setLeaseToken(task.getLeaseToken()); + attempt.setRequestSnapshot(JSONUtil.toJsonStr(java.util.Map.of( + "taskType", task.getTaskType(), "taskKey", task.getTaskKey()))); + attempt.setStartedTime(LocalDateTime.now()); + attemptMapper.insert(attempt); + try { + TaskResult result = runTask(task); + if (result.success()) { + baseMapper.complete(task.getId(), task.getLeaseToken(), result.externalStatus(), LocalDateTime.now()); + } else if (result.terminalFailure()) { + baseMapper.fail(task.getId(), task.getLeaseToken(), result.errorCode(), result.errorMessage(), LocalDateTime.now()); + } else { + retryTask(task, result.externalStatus(), result.errorCode(), result.errorMessage()); + } + attempt.setExternalStatus(result.externalStatus()); + attempt.setResponseSnapshot(JSONUtil.toJsonStr(java.util.Map.of( + "success", result.success(), "externalStatus", StrUtil.nullToEmpty(result.externalStatus())))); + attempt.setErrorCode(result.errorCode()); + attempt.setErrorMessage(result.errorMessage()); + } catch (IllegalArgumentException | IllegalStateException exception) { + baseMapper.fail(task.getId(), task.getLeaseToken(), "CONFIG_OR_ARGUMENT", exception.getMessage(), LocalDateTime.now()); + attempt.setErrorCode("CONFIG_OR_ARGUMENT"); + attempt.setErrorMessage(exception.getMessage()); + } catch (Exception exception) { + retryTask(task, "EXCEPTION", "NETWORK_OR_SYSTEM", exception.getMessage()); + attempt.setErrorCode("NETWORK_OR_SYSTEM"); + attempt.setErrorMessage(exception.getMessage()); + } finally { + attempt.setFinishedTime(LocalDateTime.now()); + attemptMapper.updateById(attempt); + } + }); + } + + /** + * 执行单个任务并将业务当前状态映射为成功或可重试结果。 + * + * @param task 已验证执行租约且恢复租户上下文的任务事实 + * @return 不含渠道敏感报文的任务状态机结果 + */ + private TaskResult runTask(ThirdPaymentTaskEntity task) { + ThirdPaymentTaskTypeEnum type = ThirdPaymentTaskTypeEnum.fromCode(task.getTaskType()); + ThirdPayOrderEntity payOrder; + switch (type) { + case PAY_CREATE: + payOrder = payOrderService.getById(task.getPayOrderId()); + if (payOrder == null || !ThirdPayOrderStatusEnum.PAY_UNPAID.getType().equals(payOrder.getStatus())) { + return TaskResult.success(payOrder == null ? "MISSING" : payOrder.getStatus()); + } + if (StrUtil.isNotBlank(payOrder.getPayRes())) { + // 同步响应可能在 PAY_CREATE 已被领取后才提交,此时创建阶段已完成,只保留唯一 PAY_QUERY。 + paymentTaskEnqueuer.enqueuePayQuery(payOrder.getTenantId(), payOrder.getId(), payOrder.getPayNo()); + return TaskResult.success("CREATE_ACCEPTED"); + } + // 创建响应丢失时先查渠道事实;App 支付参数无法由后台安全交付给原客户端,禁止盲目新建业务单号。 + appPlusService.queryPayResult(payOrder); + payOrder = payOrderService.getById(task.getPayOrderId()); + return payOrder == null || !ThirdPayOrderStatusEnum.PAY_UNPAID.getType().equals(payOrder.getStatus()) + ? TaskResult.success(payOrder == null ? "MISSING" : payOrder.getStatus()) + : TaskResult.retry("CREATE_RESULT_UNKNOWN", "PAY_CREATE_RESULT_UNKNOWN", "支付创建结果尚未确认"); + case PAY_QUERY: + payOrder = payOrderService.getById(task.getPayOrderId()); + if (payOrder == null || !ThirdPayOrderStatusEnum.PAY_UNPAID.getType().equals(payOrder.getStatus())) { + return TaskResult.success(payOrder == null ? "MISSING" : payOrder.getStatus()); + } + appPlusService.queryPayResult(payOrder); + return settleByPayStatus(payOrderService.getById(task.getPayOrderId())); + case REFUND_CREATE: + case REFUND_QUERY: + payOrder = payOrderService.getById(task.getPayOrderId()); + if (payOrder == null || !ThirdPayOrderStatusEnum.REFUNDING.getType().equals(payOrder.getStatus())) { + return TaskResult.success(payOrder == null ? "MISSING" : payOrder.getStatus()); + } + appPlusService.queryRefundResult(payOrder); + payOrder = payOrderService.getById(task.getPayOrderId()); + if (type == ThirdPaymentTaskTypeEnum.REFUND_CREATE + && payOrder != null && StrUtil.isNotBlank(payOrder.getRefundRes())) { + paymentTaskEnqueuer.enqueueRefundQuery(payOrder.getTenantId(), payOrder.getId(), + payOrder.getPayNo(), payOrder.getRefundNo()); + return TaskResult.success("REFUND_ACCEPTED"); + } + return settleByRefundStatus(payOrder); + case FZ_CREATE: + payOrder = payOrderService.getById(task.getPayOrderId()); + if (payOrder == null || !ThirdPayOrderStatusEnum.PAY_SUCCESS.getType().equals(payOrder.getStatus())) { + return TaskResult.success("PAY_NOT_SUCCESS"); + } + if (AppPlusPlatformTypeEnum.WECHAT.getType().equals(payOrder.getPayPlatformType())) { + wechatFzService.create(payOrder); + ThirdFzOrderEntity createdWechat = fzOrderService.getByPayNo(payOrder.getPayNo()); + if (createdWechat == null) { + return TaskResult.retry("PROCESSING", "FZ_NOT_CREATED", "微信分账记录尚未生成"); + } + paymentTaskEnqueuer.enqueueFzQuery(payOrder.getTenantId(), payOrder.getId(), createdWechat.getId(), + createdWechat.getPayNo(), createdWechat.getFzNo()); + } else { + ThirdFzOrderEntity createdAli = aliPayService.ensureDirectPayFzOrder(payOrder); + if (createdAli == null) { + return TaskResult.retry("PROCESSING", "FZ_NOT_CREATED", "支付宝分账记录尚未生成"); + } + // 历史成功订单可能没有 payTime;此时以当前处理时间作为安全下限,仍保证确认结算不会早于本次发现。 + LocalDateTime settleReadyTime = payOrder.getPayTime() == null + ? LocalDateTime.now().plusSeconds(30) : payOrder.getPayTime().plusSeconds(30); + paymentTaskEnqueuer.enqueueFzSettleConfirm(payOrder.getTenantId(), payOrder.getId(), createdAli.getId(), + createdAli.getPayNo(), createdAli.getFzNo(), settleReadyTime); + } + return fzCreated(payOrder.getPayNo()); + case FZ_SETTLE_CONFIRM: + ThirdFzOrderEntity settleOrder = fzOrderService.getById(task.getFzOrderId()); + payOrder = settleOrder == null ? null : payOrderService.getByPayNo(settleOrder.getPayNo()); + if (settleOrder == null || payOrder == null) { + return TaskResult.success("MISSING"); + } + String settleStatus = aliPayService.confirmDirectPaySettlementStage(settleOrder, payOrder); + if ("SUCCESS".equals(settleStatus)) { + paymentTaskEnqueuer.enqueueFzRoyalty(payOrder.getTenantId(), payOrder.getId(), settleOrder.getId(), + settleOrder.getPayNo(), settleOrder.getFzNo()); + } + return settleStageResult(settleStatus, "ALIPAY_SETTLE_CONFIRM", "支付宝确认结算"); + case FZ_ROYALTY: + ThirdFzOrderEntity royaltyOrder = fzOrderService.getById(task.getFzOrderId()); + payOrder = royaltyOrder == null ? null : payOrderService.getByPayNo(royaltyOrder.getPayNo()); + if (royaltyOrder == null || payOrder == null) { + return TaskResult.success("MISSING"); + } + String royaltyStatus = aliPayService.submitDirectPayRoyaltyStage(royaltyOrder, payOrder); + if ("SUBMITTED".equals(royaltyStatus) || "PROCESSING".equals(royaltyStatus)) { + paymentTaskEnqueuer.enqueueFzRoyaltyQuery(payOrder.getTenantId(), payOrder.getId(), royaltyOrder.getId(), + royaltyOrder.getPayNo(), royaltyOrder.getFzNo()); + } + return settleStageResult(royaltyStatus, "ALIPAY_ROYALTY", "支付宝平台抽佣"); + case FZ_ROYALTY_QUERY: + ThirdFzOrderEntity royaltyQueryOrder = fzOrderService.getById(task.getFzOrderId()); + payOrder = royaltyQueryOrder == null ? null : payOrderService.getByPayNo(royaltyQueryOrder.getPayNo()); + if (royaltyQueryOrder == null || payOrder == null) { + return TaskResult.success("MISSING"); + } + return settleStageResult(aliPayService.queryDirectPayRoyaltyStage(royaltyQueryOrder, payOrder), + "ALIPAY_ROYALTY_QUERY", "支付宝平台抽佣查询"); + case FZ_QUERY: + case FZ_REFUND_QUERY: + ThirdFzOrderEntity fzOrder = fzOrderService.getById(task.getFzOrderId()); + payOrder = fzOrder == null ? null : payOrderService.getByPayNo(fzOrder.getPayNo()); + if (fzOrder == null || payOrder == null) { + return TaskResult.success("MISSING"); + } + if (AppPlusPlatformTypeEnum.WECHAT.getType().equals(payOrder.getPayPlatformType())) { + if (type == ThirdPaymentTaskTypeEnum.FZ_REFUND_QUERY) { + wechatFzService.pollFzRefundOrder(fzOrder); + } else { + wechatFzService.pollFzOrder(fzOrder); + } + } else if (type == ThirdPaymentTaskTypeEnum.FZ_REFUND_QUERY) { + aliPayService.pollDirectPayLifecycle(fzOrder, payOrder); + } else { + // 历史 FZ_QUERY 任务只完成迁移,支付宝后续由三个独立阶段任务继续推进。 + return TaskResult.success("MIGRATED_TO_ALIPAY_STAGES"); + } + ThirdFzOrderEntity latestFz = fzOrderService.getById(fzOrder.getId()); + return settleByFzStatus(latestFz, type == ThirdPaymentTaskTypeEnum.FZ_REFUND_QUERY); + case BIZ_CALLBACK: + payOrder = payOrderService.getById(task.getPayOrderId()); + if (payOrder == null || YesNo.YES.equals(YesNo.of(payOrder.getIsBizCallback()))) { + return TaskResult.success("CALLBACK_DONE"); + } + // queryPayResult 同时覆盖 PAY_SUCCESS 和 REFUND_SUCCESS 的后置回调补偿; + // queryRefundResult 只接受 REFUNDING,不能补偿“退款已成功但业务回调丢失”的窗口。 + appPlusService.queryPayResult(payOrder); + ThirdPayOrderEntity callbackLatest = payOrderService.getById(task.getPayOrderId()); + return YesNo.YES.equals(YesNo.of(callbackLatest == null ? null : callbackLatest.getIsBizCallback())) + ? TaskResult.success("CALLBACK_DONE") : TaskResult.retry("CALLBACK_PENDING", "CALLBACK_PENDING", "业务回调尚未完成"); + case AGGREGATE_SESSION: + aggregateOrderPayService.processPendingSession(task.getAggregateSessionId()); + ThirdAggregatePaySessionEntity session = aggregateSessionService.getById(task.getAggregateSessionId()); + if (isAggregateSessionProcessing(session)) { + return TaskResult.retry("PROCESSING", "SESSION_PROCESSING", "聚合支付会话尚未收敛"); + } + return TaskResult.success(session == null ? "MISSING" : session.getStatus()); + case PAY_CLOSE: + payOrder = payOrderService.getById(task.getPayOrderId()); + if (payOrder == null || !ThirdPayOrderStatusEnum.PAY_UNPAID.getType().equals(payOrder.getStatus())) { + return TaskResult.success(payOrder == null ? "MISSING" : payOrder.getStatus()); + } + // 历史订单也必须由渠道查单或关单确认后才能收敛,禁止仅因本地时间到期就标记关闭。 + appPlusService.queryPayResult(payOrder); + return settleByPayStatus(payOrderService.getById(task.getPayOrderId())); + case AGGREGATE_CLOSE: + ThirdAggregatePaySessionEntity aggregate = aggregateSessionService.getById(task.getAggregateSessionId()); + if (aggregate == null) { + return TaskResult.success("MISSING"); + } + if (StrUtil.isBlank(aggregate.getPayNo())) { + // 从未绑定渠道子订单的历史会话没有外部支付事实,可以安全本地关闭。 + aggregateSessionService.markTerminal(aggregate.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED); + return TaskResult.success("NO_CHANNEL_ORDER"); + } + // 渠道单始终交给唯一 AGGREGATE_SESSION 任务收敛,避免两个任务键并发重复查单或关单。 + paymentTaskEnqueuer.enqueueAggregateSession(aggregate.getTenantId(), aggregate.getId(), + aggregate.getAggregateNo(), aggregate.getPayNo(), LocalDateTime.now()); + return TaskResult.success("DELEGATED_TO_AGGREGATE_SESSION"); + case RECONCILE: + return reconcilePayOrder(task); + default: + return TaskResult.retry("UNIMPLEMENTED", "TASK_NOT_IMPLEMENTED", "任务类型尚未接入业务处理器"); + } + } + + /** + * 对账任务只修复任务事实缺口,不直接访问支付宝或微信,外部调用仍由对应稳定任务执行。 + * @param task 对账任务,必须关联支付订单 + * @return 对账已完成或等待下一阶段任务的结果 + */ + private TaskResult reconcilePayOrder(ThirdPaymentTaskEntity task) { + ThirdPayOrderEntity payOrder = payOrderService.getById(task.getPayOrderId()); + if (payOrder == null) { + return TaskResult.success("MISSING"); + } + String status = payOrder.getStatus(); + if (ThirdPayOrderStatusEnum.PAY_UNPAID.getType().equals(status)) { + paymentTaskEnqueuer.enqueuePayQuery(payOrder.getTenantId(), payOrder.getId(), payOrder.getPayNo()); + } else if (ThirdPayOrderStatusEnum.REFUNDING.getType().equals(status) + && StrUtil.isNotBlank(payOrder.getRefundNo())) { + paymentTaskEnqueuer.enqueueRefundQuery(payOrder.getTenantId(), payOrder.getId(), payOrder.getPayNo(), payOrder.getRefundNo()); + } else if ((ThirdPayOrderStatusEnum.PAY_SUCCESS.getType().equals(status) + || ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType().equals(status)) + && !YesNo.YES.equals(YesNo.of(payOrder.getIsBizCallback()))) { + paymentTaskEnqueuer.enqueueBusinessCallback(payOrder.getTenantId(), payOrder.getId(), payOrder.getPayNo(), status); + } + return TaskResult.success("RECONCILED"); + } + + /** + * 判断聚合会话是否仍需由相同任务键继续补偿。 + * + * @param session 业务处理后的最新聚合会话;记录缺失时允许为空 + * @return 活动状态,或已过期但仍绑定渠道子订单时返回 {@code true} + */ + private boolean isAggregateSessionProcessing(ThirdAggregatePaySessionEntity session) { + if (session == null) { + return false; + } + if (List.of(ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(), + ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(), + ThirdAggregatePaySessionStatusEnum.PAYING.getType()).contains(session.getStatus())) { + return true; + } + return ThirdAggregatePaySessionStatusEnum.EXPIRED.getType().equals(session.getStatus()) + && StrUtil.isNotBlank(session.getPayNo()); + } + + /** + * 按支付状态判定查单是否收敛。 + * + * @param order 渠道查单或关单后的最新支付订单 + * @return 未支付时返回可重试结果,其他状态或记录缺失时返回成功结果 + */ + private TaskResult settleByPayStatus(ThirdPayOrderEntity order) { + if (order == null) return TaskResult.success("MISSING"); + return ThirdPayOrderStatusEnum.PAY_UNPAID.getType().equals(order.getStatus()) + ? TaskResult.retry("PROCESSING", "PAYMENT_PROCESSING", "渠道仍未返回终态") + : TaskResult.success(order.getStatus()); + } + + /** + * 按退款状态判定查单是否收敛。 + * + * @param order 渠道退款查询后的最新支付订单 + * @return 退款中时返回可重试结果,其他状态或记录缺失时返回成功结果 + */ + private TaskResult settleByRefundStatus(ThirdPayOrderEntity order) { + if (order == null) return TaskResult.success("MISSING"); + return ThirdPayOrderStatusEnum.REFUNDING.getType().equals(order.getStatus()) + ? TaskResult.retry("PROCESSING", "REFUND_PROCESSING", "退款仍在处理中") + : TaskResult.success(order.getStatus()); + } + + /** + * 按分账状态判定当前阶段是否完成。 + * + * @param order 渠道分账或退分账查询后的最新分账订单 + * @param refund 是否判断退分账阶段 + * @return 处理中返回可重试结果,其他状态或记录缺失时返回成功结果 + */ + private TaskResult settleByFzStatus(ThirdFzOrderEntity order, boolean refund) { + if (order == null) return TaskResult.success("MISSING"); + String processing = refund ? ThirdFzOrderStatusEnum.FZ_REFUNDING.getType() : ThirdFzOrderStatusEnum.FZ_UNPAID.getType(); + return processing.equals(order.getStatus()) ? TaskResult.retry("PROCESSING", "FZ_PROCESSING", "分账仍在处理中") : TaskResult.success(order.getStatus()); + } + + /** + * 判断分账记录是否已经物化。 + * + * @param payNo 稳定支付订单号 + * @return 已创建分账记录时成功,否则返回可重试结果 + */ + private TaskResult fzCreated(String payNo) { + return fzOrderService.getByPayNo(payNo) == null + ? TaskResult.retry("PROCESSING", "FZ_NOT_CREATED", "分账记录尚未生成") + : TaskResult.success("FZ_CREATED"); + } + + /** + * 将支付宝单阶段结果转换为任务状态;处理中保留同一任务号退避重试。 + * + * @param stageStatus 支付宝阶段状态 + * @param stageCode 任务错误码前缀 + * @param stageName 中文阶段名称 + * @return 对应成功、重试或确定失败结果 + */ + private TaskResult settleStageResult(String stageStatus, String stageCode, String stageName) { + String status = StrUtil.blankToDefault(stageStatus, "PROCESSING"); + if ("PROCESSING".equals(status)) { + return TaskResult.retry(status, stageCode + "_PROCESSING", stageName + "仍在处理中"); + } + if ("FAILED".equals(status)) { + return TaskResult.failed(status, stageCode + "_FAILED", stageName + "已明确失败"); + } + return TaskResult.success(status); + } + + /** + * 按尝试次数计算指数退避,并在达到阈值后进入 ALERT。 + * + * @param task 当前持有有效租约的支付任务 + * @param externalStatus 最近一次渠道状态 + * @param errorCode 可诊断但不含敏感信息的错误码 + * @param message 可诊断但不含敏感信息的错误摘要 + */ + private void retryTask(ThirdPaymentTaskEntity task, String externalStatus, String errorCode, String message) { + int attempts = task.getAttemptCount() == null ? 1 : task.getAttemptCount(); + long seconds = Math.min(maxBackoffSeconds, 5L * (1L << Math.min(attempts, 8))); + String status = attempts >= alertAttempts ? ThirdPaymentTaskStatusEnum.ALERT.getCode() : ThirdPaymentTaskStatusEnum.RETRY.getCode(); + baseMapper.retry(task.getId(), task.getLeaseToken(), status, LocalDateTime.now().plusSeconds(seconds), + externalStatus, errorCode, StrUtil.subPre(StrUtil.blankToDefault(message, "支付任务等待重试"), 1000)); + } + + /** + * 生成当前进程生命周期内稳定且可诊断的 Worker 标识。 + * + * @return 主机名与随机进程标识组合;主机名不可用时使用服务前缀 + */ + private String resolveWorkerId() { + try { + return InetAddress.getLocalHost().getHostName() + ":" + UUID.randomUUID(); + } catch (Exception exception) { + return "thirdparty:" + UUID.randomUUID(); + } + } + + /** + * 实时路径创建任务。调用方必须先填充租户、类型和稳定任务键;本方法不会重写业务号。 + * + * @param task 已包含租户、类型、稳定任务键和关联业务号的任务事实 + * @return 新任务写入或相同稳定任务键已经存在时均返回 {@code true} + */ + @Override + public boolean enqueue(ThirdPaymentTaskEntity task) { + if (task == null || task.getTenantId() == null || StrUtil.isBlank(task.getTaskType()) + || StrUtil.isBlank(task.getTaskKey())) { + throw new IllegalArgumentException("支付任务的租户、类型和任务键不能为空"); + } + ThirdPaymentTaskTypeEnum.fromCode(task.getTaskType()); + LocalDateTime now = LocalDateTime.now(); + task.setId(task.getId() == null ? IdWorker.getId() : task.getId()); + task.setStatus(StrUtil.blankToDefault(task.getStatus(), ThirdPaymentTaskStatusEnum.READY.getCode())); + task.setNextRunTime(task.getNextRunTime() == null ? now : task.getNextRunTime()); + task.setPriority(task.getPriority() == null ? 100 : task.getPriority()); + task.setAttemptCount(task.getAttemptCount() == null ? 0 : task.getAttemptCount()); + task.setCreateTime(task.getCreateTime() == null ? now : task.getCreateTime()); + task.setUpdateTime(now); + task.setDelFlag(StrUtil.blankToDefault(task.getDelFlag(), "0")); + baseMapper.insertIgnore(task); + return true; + } + + /** + * 将允许人工恢复的终态任务放回重试队列,保留原任务键和全部外部业务号。 + * + * @param taskId 当前租户内待恢复的任务主键 + * @return 原状态允许人工重试且更新成功时返回 {@code true} + */ + @Override + public boolean manualRetry(Long taskId) { + return baseMapper.manualRetry(taskId, LocalDateTime.now()) > 0; + } + + /** + * 内部任务结果,不把渠道原始敏感快照带出任务分发接口。 + * + * @param success 当前任务是否已经完成 + * @param terminalFailure 是否为不可自动重试的确定性失败 + * @param externalStatus 最近一次渠道或本地状态 + * @param errorCode 失败或重试错误码 + * @param errorMessage 失败或重试错误摘要 + */ + private record TaskResult(boolean success, boolean terminalFailure, String externalStatus, + String errorCode, String errorMessage) { + /** + * 创建成功结果。 + * @param status 最终渠道或本地状态 + * @return 已完成任务结果 + */ + static TaskResult success(String status) { return new TaskResult(true, false, status, null, null); } + /** + * 创建可重试结果。 + * @param status 最近一次渠道状态 + * @param code 可诊断错误码 + * @param message 可诊断错误摘要 + * @return 等待退避重试的任务结果 + */ + static TaskResult retry(String status, String code, String message) { + return new TaskResult(false, false, status, code, message); + } + /** + * 创建确定性失败结果。 + * @param status 最近一次渠道状态 + * @param code 确定性错误码 + * @param message 确定性错误摘要 + * @return 不自动重试的失败任务结果 + */ + static TaskResult failed(String status, String code, String message) { + return new TaskResult(false, true, status, code, message); + } + } + + /** 支付业务回调状态的本地安全枚举。 */ + private enum YesNo { + /** 是。 */ + YES("1"), + /** 否。 */ + NO("0"); + /** 支付订单业务回调字段持久化编码。 */ + private final String code; + /** + * 创建业务回调状态。 + * @param code {@code third_pay_order.is_biz_callback} 持久化编码 + */ + YesNo(String code) { this.code = code; } + /** + * 按持久化编码解析业务回调状态。 + * @param code 支付订单业务回调编码,允许为空 + * @return 匹配状态;未知编码返回空 + */ + static YesNo of(String code) { + for (YesNo value : values()) if (value.code.equals(code)) return value; + return null; + } + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPayOrderMapper.xml b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPayOrderMapper.xml index 18b8417b..4a903930 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPayOrderMapper.xml +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPayOrderMapper.xml @@ -55,6 +55,9 @@ and a.user_id = #{dto.userId} + + and a.mch_id = #{dto.mchId} + and a.pay_time between #{dto.payTimeList[0]} and #{dto.payTimeList[1]} @@ -71,4 +74,4 @@ order by a.create_time desc, a.id desc - \ No newline at end of file + diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml new file mode 100644 index 00000000..9a013c7a --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml @@ -0,0 +1,331 @@ + + + + + + + INSERT IGNORE INTO third_payment_task + (id, tenant_id, task_type, task_key, pay_order_id, fz_order_id, aggregate_session_id, + pay_no, refund_no, fz_no, aggregate_no, status, next_run_time, priority, attempt_count, + create_time, update_time, del_flag) + VALUES + (#{id}, #{tenantId}, #{taskType}, #{taskKey}, #{payOrderId}, #{fzOrderId}, #{aggregateSessionId}, + #{payNo}, #{refundNo}, #{fzNo}, #{aggregateNo}, #{status}, #{nextRunTime}, #{priority}, + #{attemptCount}, #{createTime}, #{updateTime}, #{delFlag}) + + + + UPDATE third_payment_task + SET status = 'RETRY', next_run_time = #{now}, lease_owner = NULL, lease_token = NULL, + lease_expire_time = NULL, last_error_code = 'LEASE_EXPIRED', + last_error_message = 'Worker租约到期,等待Quartz重新分发', update_time = #{now} + WHERE task_type = #{taskType} AND status = 'PROCESSING' AND lease_expire_time <= #{now} + AND del_flag = '0' + + + + + + UPDATE third_payment_task + SET status = 'PROCESSING', lease_owner = #{owner}, lease_token = #{token}, + lease_expire_time = #{expireTime}, attempt_count = attempt_count + 1, update_time = #{now} + WHERE id = #{id} AND status IN ('READY', 'RETRY', 'ALERT') + AND next_run_time <= #{now} AND del_flag = '0' + + + + UPDATE third_payment_task + SET lease_expire_time = #{expireTime}, update_time = #{now} + WHERE id = #{id} AND status = 'PROCESSING' AND lease_token = #{token} + AND lease_owner = #{owner} AND del_flag = '0' + + + + UPDATE third_payment_task + SET status = 'RETRY', next_run_time = #{nextRunTime}, lease_owner = NULL, lease_token = NULL, + lease_expire_time = NULL, last_error_code = 'WORKER_BUSY', last_error_message = #{message}, + update_time = NOW() + WHERE id = #{id} AND status = 'PROCESSING' AND lease_token = #{token} AND del_flag = '0' + + + + UPDATE third_payment_task + SET status = 'SUCCESS', last_external_status = #{externalStatus}, last_error_code = NULL, + last_error_message = NULL, finished_time = #{now}, lease_owner = NULL, lease_token = NULL, + lease_expire_time = NULL, update_time = #{now} + WHERE id = #{id} AND status = 'PROCESSING' AND lease_token = #{token} AND del_flag = '0' + + + + UPDATE third_payment_task + SET status = #{status}, last_external_status = #{externalStatus}, last_error_code = NULL, + last_error_message = NULL, finished_time = #{now}, update_time = #{now} + WHERE tenant_id = #{tenantId} AND task_key = #{taskKey} + AND status IN ('READY', 'RETRY', 'ALERT') AND del_flag = '0' + + + + UPDATE third_payment_task + SET status = #{status}, next_run_time = #{nextRunTime}, last_external_status = #{externalStatus}, + last_error_code = #{errorCode}, last_error_message = #{errorMessage}, lease_owner = NULL, + lease_token = NULL, lease_expire_time = NULL, update_time = NOW() + WHERE id = #{id} AND status = 'PROCESSING' AND lease_token = #{token} AND del_flag = '0' + + + + UPDATE third_payment_task + SET status = 'FAILED', last_error_code = #{errorCode}, last_error_message = #{errorMessage}, + finished_time = #{now}, lease_owner = NULL, lease_token = NULL, lease_expire_time = NULL, + update_time = #{now} + WHERE id = #{id} AND status = 'PROCESSING' AND lease_token = #{token} AND del_flag = '0' + + + + + + UPDATE third_payment_task + SET status = 'RETRY', next_run_time = #{nextRunTime}, finished_time = NULL, + lease_owner = NULL, lease_token = NULL, lease_expire_time = NULL, + last_error_code = NULL, last_error_message = NULL, update_time = #{nextRunTime} + WHERE id = #{id} AND status IN ('FAILED', 'ALERT', 'CANCELLED') AND del_flag = '0' + + diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJobTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJobTest.java index 2de19b7c..c1160fa8 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJobTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJobTest.java @@ -3,12 +3,9 @@ package com.bsd.pjcloud.thirdparty.job; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum; -import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; -import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService; import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService; -import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; import com.bsd.pjcloud.thirdparty.service.ThirdPayPollingCoordinator; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -22,8 +19,6 @@ import java.util.List; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; /** @@ -39,17 +34,11 @@ class ThirdAggregateOrderPayJobTest { private ThirdAggregatePaySessionService aggregatePaySessionService; /** - * 聚合支付编排服务 Mock,用于确认历史关闭路径不再调用支付平台处理流程。 + * 聚合支付编排服务 Mock,用于确认历史记录继续交由可信查单路径处理。 */ @Mock private ThirdAggregateOrderPayService aggregateOrderPayService; - /** - * 第三方子订单服务 Mock;无子订单会话关闭时不应访问该服务。 - */ - @Mock - private ThirdPayOrderService thirdPayOrderService; - /** * 自适应轮询协调器 Mock,用于允许本轮补偿扫描。 */ @@ -71,10 +60,10 @@ class ThirdAggregateOrderPayJobTest { } /** - * 验证超过一小时且没有子订单的会话直接标记关闭,不再进入第三方平台查单流程。 + * 验证超过一小时且没有渠道单的会话仍委托业务编排服务,由业务层决定本地终态。 */ @Test - void compensationShouldCloseHistoricalSessionWithoutCallingPaymentPlatform() { + void compensationShouldDelegateHistoricalSessionWithoutChannelOrder() { ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity(); session.setId(201L); session.setTenantId(66L); @@ -85,36 +74,27 @@ class ThirdAggregateOrderPayJobTest { job.handleExpiredSessionCompensation(); - verify(aggregatePaySessionService).markTerminal( - session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED); + verify(aggregateOrderPayService).processPendingSession(session.getId()); verify(pollingCoordinator).finishAggregateCompensationPolling(true); - verifyNoInteractions(aggregateOrderPayService, thirdPayOrderService); } /** - * 验证历史子订单即使已保存支付成功事实,也只关闭聚合会话而不执行历史状态恢复。 + * 验证历史会话已绑定渠道单时不会在 Job 中盲关,而是委托编排服务核对渠道事实。 */ @Test - void compensationShouldNotRecoverHistoricalSessionFromSuccessfulChildOrder() { + void compensationShouldDelegateHistoricalSessionWithChannelOrder() { ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity(); session.setId(202L); session.setTenantId(66L); session.setPayNo("historical-child-pay-no"); session.setStatus(ThirdAggregatePaySessionStatusEnum.EXPIRED.getType()); session.setCreateTime(LocalDateTime.now().minusHours(2)); - ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity(); - payOrder.setId(302L); - payOrder.setPayNo(session.getPayNo()); - payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); when(pollingCoordinator.tryStartAggregateCompensationPolling()).thenReturn(true); when(aggregatePaySessionService.list(any(Wrapper.class))).thenReturn(List.of(session), List.of()); - when(thirdPayOrderService.getByPayNo(session.getPayNo())).thenReturn(payOrder); job.handleExpiredSessionCompensation(); - verify(aggregatePaySessionService).markTerminal( - session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED); - verify(aggregatePaySessionService, never()).syncFromPayOrder(any()); - verifyNoInteractions(aggregateOrderPayService); + verify(aggregateOrderPayService).processPendingSession(session.getId()); + verify(pollingCoordinator).finishAggregateCompensationPolling(true); } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJobTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJobTest.java index d846fc43..0f1d3589 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJobTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJobTest.java @@ -2,9 +2,15 @@ package com.bsd.pjcloud.thirdparty.job; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService; import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusService; +import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatFzService; +import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; import com.bsd.pjcloud.thirdparty.service.ThirdPayPollingCoordinator; import org.junit.jupiter.api.AfterEach; @@ -14,6 +20,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; import java.util.List; @@ -41,6 +48,24 @@ class ThirdAppPlusPayJobTest { @Mock private ThirdAppPlusService thirdAppPlusService; + /** + * 分账订单服务 Mock,用于验证最终分账标记的重启恢复。 + */ + @Mock + private ThirdFzOrderService thirdFzOrderService; + + /** + * 微信分账服务 Mock,补齐被测任务的构造依赖。 + */ + @Mock + private ThirdAppPlusWechatFzService thirdAppPlusWechatFzService; + + /** + * 支付宝直付通服务 Mock,补齐被测任务的构造依赖。 + */ + @Mock + private ThirdAppPlusAliPayService thirdAppPlusAliPayService; + /** * 自适应轮询协调器 Mock,用于控制本轮是否允许访问数据库。 */ @@ -112,7 +137,64 @@ class ThirdAppPlusPayJobTest { job.closeHistoricalOrderCodeOrders(); - verify(thirdPayOrderService).update(any(Wrapper.class)); + verify(thirdPayOrderService).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); verifyNoInteractions(thirdAppPlusService); } + + /** + * 验证 App 待支付订单不受一小时创建窗口限制,并在仍未支付时更新时间轮转到批次队尾。 + */ + @Test + void handleAppPaymentOrdersShouldRecoverHistoricalOrderAndRotatePendingRecord() { + ThirdPayOrderEntity order = new ThirdPayOrderEntity(); + order.setId(102L); + order.setTenantId(66L); + order.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType()); + order.setCreateTime(LocalDateTime.now().minusHours(2)); + order.setUpdateTime(LocalDateTime.now().minusHours(2)); + when(pollingCoordinator.tryStartAppPaymentPolling()).thenReturn(true); + when(thirdPayOrderService.list(any(Wrapper.class))).thenReturn(List.of(order)); + when(thirdPayOrderService.getById(order.getId())).thenReturn(order); + + job.handleAppPaymentOrders(); + + verify(thirdAppPlusService).queryPayResult(order); + org.mockito.ArgumentCaptor updateCaptor = + org.mockito.ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertNotNull(updateCaptor.getValue().getUpdateTime()); + verify(pollingCoordinator).finishAppPaymentPolling(true); + Assertions.assertNull(TenantContextHolder.getTenantId()); + } + + /** + * 验证服务在分账终态落库后退出,下一轮仍能把最终抽佣金额和成功标记补写到支付订单。 + */ + @Test + void reconcileSuccessfulFzOrdersShouldRecoverInterruptedFinalMarker() { + ThirdPayOrderEntity order = new ThirdPayOrderEntity(); + order.setId(101L); + order.setTenantId(66L); + order.setPayNo("pay-101"); + order.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); + order.setEnableFz(YesNoEnum.YES.getCode()); + order.setIsFzSuccess(YesNoEnum.NO.getCode()); + ThirdFzOrderEntity fzOrder = new ThirdFzOrderEntity(); + fzOrder.setId(order.getId()); + fzOrder.setPayNo(order.getPayNo()); + fzOrder.setPrice(123); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_SUCCESS.getType()); + when(thirdPayOrderService.list(any(Wrapper.class))).thenReturn(List.of(order)); + when(thirdPayOrderService.getById(order.getId())).thenReturn(order); + when(thirdFzOrderService.getByPayNo(order.getPayNo())).thenReturn(fzOrder); + + ReflectionTestUtils.invokeMethod(job, "reconcileSuccessfulFzOrders"); + + org.mockito.ArgumentCaptor updateCaptor = + org.mockito.ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(123, updateCaptor.getValue().getFzPrice()); + Assertions.assertEquals(YesNoEnum.YES.getCode(), updateCaptor.getValue().getIsFzSuccess()); + Assertions.assertNull(TenantContextHolder.getTenantId()); + } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapperSqlContractTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapperSqlContractTest.java new file mode 100644 index 00000000..44c603b6 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapperSqlContractTest.java @@ -0,0 +1,137 @@ +package com.bsd.pjcloud.thirdparty.mapper.pay; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * 支付任务 Mapper 原子条件契约测试,防止维护 SQL 时移除跨实例安全所需的状态或租约约束。 + */ +class ThirdPaymentTaskMapperSqlContractTest { + + /** MyBatis 支付任务映射资源路径。 */ + private static final String MAPPER_RESOURCE = "mapper/ThirdPaymentTaskMapper.xml"; + + /** 支付任务表与高频业务扫描索引迁移相对项目根目录的路径。 */ + private static final String PAYMENT_TASK_MIGRATION = "db/20260824_add_third_payment_task.sql"; + + /** 测试启动时读取的 Mapper XML。 */ + private static String mapperXml; + + /** 测试启动时读取的支付任务数据库迁移。 */ + private static String migrationSql; + + /** + * 从测试类路径读取本轮实际参与编译的 Mapper XML。 + * + * @throws IOException 资源读取失败时抛出 + */ + @BeforeAll + static void loadMapperXml() throws IOException { + try (InputStream input = ThirdPaymentTaskMapperSqlContractTest.class.getClassLoader() + .getResourceAsStream(MAPPER_RESOURCE)) { + Assertions.assertNotNull(input, "支付任务 Mapper XML 必须进入运行时类路径"); + mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + migrationSql = readProjectFile(PAYMENT_TASK_MIGRATION); + } + + /** + * 验证领取动作必须同时受可领取原状态和到期时间约束,多个 thirdparty 实例只能有一个 CAS 成功。 + */ + @Test + void claimShouldKeepOriginalStatusAndDueTimeConditions() { + String sql = statement("update", "claim"); + Assertions.assertAll( + () -> Assertions.assertTrue(sql.contains("status IN ('READY', 'RETRY', 'ALERT')")), + () -> Assertions.assertTrue(sql.contains("next_run_time <= #{now}"))); + } + + /** + * 验证完成、重试、失败和队列释放都校验当前租约 Token,过期 Worker 不能覆盖新 Worker。 + */ + @Test + void resultUpdatesShouldRequireCurrentLeaseToken() { + for (String id : new String[]{"release", "complete", "retry", "fail"}) { + String sql = statement("update", id); + Assertions.assertTrue(sql.contains("status = 'PROCESSING'"), id + " 必须限制原状态"); + Assertions.assertTrue(sql.contains("lease_token = #{token}"), id + " 必须限制当前租约令牌"); + } + } + + /** + * 验证仅恢复已过期的处理中租约,未到期 Worker 不会被 Quartz 下一轮提前抢占。 + */ + @Test + void recoveryShouldOnlyReleaseExpiredProcessingLeases() { + String sql = statement("update", "recoverExpiredLeases"); + Assertions.assertAll( + () -> Assertions.assertTrue(sql.contains("status = 'PROCESSING'")), + () -> Assertions.assertTrue(sql.contains("lease_expire_time <= #{now}")), + () -> Assertions.assertTrue(sql.contains("status = 'RETRY'"))); + } + + /** + * 验证任务离开本地队列时必须按原租约令牌和 Worker 所有者续租,失效队列项不能访问支付渠道。 + */ + @Test + void leaseRenewalShouldRequireCurrentTokenAndOwner() { + String sql = statement("update", "renewLease"); + Assertions.assertAll( + () -> Assertions.assertTrue(sql.contains("status = 'PROCESSING'")), + () -> Assertions.assertTrue(sql.contains("lease_token = #{token}")), + () -> Assertions.assertTrue(sql.contains("lease_owner = #{owner}"))); + } + + /** + * 验证高频物化扫描具备与实际谓词和排序方向匹配的业务源表索引,避免百万级数据量下周期全表扫描。 + */ + @Test + void migrationShouldCoverHighFrequencyBusinessScans() { + Assertions.assertAll( + () -> Assertions.assertTrue(migrationSql.contains("`idx_third_pay_order_task_scan` (`status`, `del_flag`, `pay_platform_type`, `update_time`, `id`)")), + () -> Assertions.assertTrue(migrationSql.contains("`idx_third_fz_order_task_scan` (`status`, `del_flag`, `update_time`, `id`)")), + () -> Assertions.assertTrue(migrationSql.contains("`idx_third_aggregate_task_scan` (`status`, `del_flag`, `update_time`, `id`)")), + () -> Assertions.assertTrue(migrationSql.contains("`idx_third_aggregate_close_scan` (`status`, `del_flag`, `create_time`, `id`)"))); + } + + /** + * 提取指定 MyBatis 语句片段。 + * + * @param element XML 元素名,例如 update + * @param id MyBatis 语句 ID + * @return 对应语句 XML 片段 + */ + private static String statement(String element, String id) { + String startToken = "<" + element + " id=\"" + id + "\">"; + int start = mapperXml.indexOf(startToken); + int end = mapperXml.indexOf("", start); + Assertions.assertTrue(start >= 0 && end > start, "缺少 MyBatis 语句:" + id); + return mapperXml.substring(start, end); + } + + /** + * 从当前 Maven 或 IDE 工作目录向父目录查找项目文件,兼容聚合构建和模块内单测两种启动方式。 + * + * @param relativePath 相对后端项目根目录的文件路径 + * @return UTF-8 文件内容 + * @throws IOException 文件读取失败或未找到后端项目根目录时抛出 + */ + private static String readProjectFile(String relativePath) throws IOException { + Path current = Path.of("").toAbsolutePath(); + while (current != null) { + Path candidate = current.resolve(relativePath); + if (Files.isRegularFile(candidate)) { + return Files.readString(candidate, StandardCharsets.UTF_8); + } + current = current.getParent(); + } + throw new IOException("未找到项目文件:" + relativePath); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentOrderReservationServiceTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentOrderReservationServiceTest.java new file mode 100644 index 00000000..17b1898d --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentOrderReservationServiceTest.java @@ -0,0 +1,70 @@ +package com.bsd.pjcloud.thirdparty.service; + +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskMapper; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.when; + +/** + * 支付订单预留事务测试,验证调用渠道前订单与首任务在同一事务方法中按顺序持久化。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdPaymentOrderReservationServiceTest { + + /** 支付订单服务 Mock。 */ + @Mock + private ThirdPayOrderService payOrderService; + + /** 支付任务写入器 Mock。 */ + @Mock + private ThirdPaymentTaskEnqueuer taskEnqueuer; + + /** 支付任务 Mapper Mock,用于补齐渠道同步响应收口依赖。 */ + @Mock + private ThirdPaymentTaskMapper taskMapper; + + /** 被测支付订单预留服务。 */ + @InjectMocks + private ThirdPaymentOrderReservationService reservationService; + + /** + * 验证订单保存成功后才写入 PAY_CREATE,并确认事务在外部渠道调用前以独立事务提交。 + * + * @throws Exception 反射读取方法声明失败时抛出 + */ + @Test + void reserveAppPayShouldPersistOrderAndCreateTaskInRequiresNewTransaction() throws Exception { + ThirdPayOrderEntity order = new ThirdPayOrderEntity(); + order.setId(101L); + order.setTenantId(9L); + order.setPayNo("pay-1001"); + when(payOrderService.save(order)).thenReturn(true); + + reservationService.reserveAppPay(order); + + InOrder inOrder = inOrder(payOrderService, taskEnqueuer); + inOrder.verify(payOrderService).save(order); + inOrder.verify(taskEnqueuer).enqueue(9L, ThirdPaymentTaskTypeEnum.PAY_CREATE, "PAY_CREATE:pay-1001", + 101L, "pay-1001", null, null, null); + Method method = ThirdPaymentOrderReservationService.class.getDeclaredMethod("reserveAppPay", ThirdPayOrderEntity.class); + Transactional transactional = method.getAnnotation(Transactional.class); + Assertions.assertAll( + () -> Assertions.assertNotNull(transactional), + () -> Assertions.assertEquals(Propagation.REQUIRES_NEW, transactional.propagation()), + () -> Assertions.assertTrue(Arrays.asList(transactional.rollbackFor()).contains(Exception.class))); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuerTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuerTest.java new file mode 100644 index 00000000..0d2d22e1 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuerTest.java @@ -0,0 +1,95 @@ +package com.bsd.pjcloud.thirdparty.service; + +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPaymentTaskEntity; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskMapper; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * 支付任务实时写入器测试,验证数据库唯一键依赖的任务键稳定性和非自增主键初始化。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdPaymentTaskEnqueuerTest { + + /** 支付任务 Mapper Mock,用于捕获实际准备写入数据库的任务事实。 */ + @Mock + private ThirdPaymentTaskMapper taskMapper; + + /** 被测任务写入器。 */ + @InjectMocks + private ThirdPaymentTaskEnqueuer enqueuer; + + /** + * 验证同一支付号重复入队始终生成相同任务键,同时每次插入都具备可用的分布式主键。 + */ + @Test + void enqueuePayQueryShouldUseStableTaskKeyAndAssignedId() { + enqueuer.enqueuePayQuery(9L, 101L, "pay-1001"); + enqueuer.enqueuePayQuery(9L, 101L, "pay-1001"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ThirdPaymentTaskEntity.class); + verify(taskMapper, times(2)).insertIgnore(captor.capture()); + List tasks = captor.getAllValues(); + Assertions.assertAll( + () -> Assertions.assertNotNull(tasks.get(0).getId()), + () -> Assertions.assertNotNull(tasks.get(1).getId()), + () -> Assertions.assertNotEquals(tasks.get(0).getId(), tasks.get(1).getId()), + () -> Assertions.assertEquals("PAY_QUERY:pay-1001", tasks.get(0).getTaskKey()), + () -> Assertions.assertEquals(tasks.get(0).getTaskKey(), tasks.get(1).getTaskKey()), + () -> Assertions.assertEquals(ThirdPaymentTaskTypeEnum.PAY_QUERY.getCode(), tasks.get(0).getTaskType()), + () -> Assertions.assertEquals("READY", tasks.get(0).getStatus()), + () -> Assertions.assertEquals(0, tasks.get(0).getAttemptCount()), + () -> Assertions.assertEquals("0", tasks.get(0).getDelFlag()), + () -> Assertions.assertNotNull(tasks.get(0).getNextRunTime())); + } + + /** + * 验证退分账查询任务键同时绑定原分账号和稳定退分账号,重试不会生成新的渠道单据。 + */ + @Test + void enqueueFzRefundQueryShouldKeepOriginalBusinessNumbers() { + enqueuer.enqueueFzRefundQuery(9L, 101L, 201L, "pay-1001", "fz-pay-1001", "fz-refund-pay-1001"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ThirdPaymentTaskEntity.class); + verify(taskMapper).insertIgnore(captor.capture()); + ThirdPaymentTaskEntity task = captor.getValue(); + Assertions.assertAll( + () -> Assertions.assertEquals("FZ_REFUND_QUERY:fz-pay-1001:fz-refund-pay-1001", task.getTaskKey()), + () -> Assertions.assertEquals(ThirdPaymentTaskTypeEnum.FZ_REFUND_QUERY.getCode(), task.getTaskType()), + () -> Assertions.assertEquals("pay-1001", task.getPayNo()), + () -> Assertions.assertEquals("fz-pay-1001", task.getFzNo()), + () -> Assertions.assertEquals(201L, task.getFzOrderId()), + () -> Assertions.assertEquals(110, task.getPriority())); + } + + /** + * 验证历史聚合关闭任务物化的会话任务沿用唯一聚合号,不产生第二个渠道处理任务键。 + */ + @Test + void enqueueAggregateSessionShouldUseStableAggregateKey() { + enqueuer.enqueueAggregateSession(9L, 301L, "aggregate-1001", "pay-1001", null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ThirdPaymentTaskEntity.class); + verify(taskMapper).insertIgnore(captor.capture()); + ThirdPaymentTaskEntity task = captor.getValue(); + Assertions.assertAll( + () -> Assertions.assertEquals("AGGREGATE_SESSION:aggregate-1001", task.getTaskKey()), + () -> Assertions.assertEquals(ThirdPaymentTaskTypeEnum.AGGREGATE_SESSION.getCode(), task.getTaskType()), + () -> Assertions.assertEquals(301L, task.getAggregateSessionId()), + () -> Assertions.assertEquals("aggregate-1001", task.getAggregateNo()), + () -> Assertions.assertEquals("pay-1001", task.getPayNo()), + () -> Assertions.assertEquals(105, task.getPriority()), + () -> Assertions.assertNotNull(task.getNextRunTime())); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java index 242ec831..03928341 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java @@ -159,6 +159,27 @@ class ThirdAggregateAliPayServiceImplTest { Assertions.assertFalse(orderCaptor.getValue().getPayRep().contains("alipay-open-id")); } + /** + * 验证商户没有配置授权 Token 时请求中完全不发送 app_auth_token,且不降级为另一套支付流程。 + * + * @throws Exception Mock 支付宝客户端声明的受检异常 + */ + @Test + void createPayShouldOmitEmptyAppAuthToken() throws Exception { + paySetting.setSettingJson(JSONUtil.toJsonStr(new MchPaySettingEntity.AliPaySetting())); + AlipayTradeCreateResponse response = new AlipayTradeCreateResponse(); + response.setCode("10000"); + response.setTradeNo("2026000000000003"); + when(alipayClient.execute(any(AlipayTradeCreateRequest.class))).thenReturn(response); + when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(true); + + service.createPay(context, paySetting, new ThirdAggregateAlipayOauthIdentity("alipay-open-id", null)); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class); + verify(alipayClient).execute(requestCaptor.capture()); + Assertions.assertFalse(requestCaptor.getValue().getTextParams().containsKey("app_auth_token")); + } + /** * 验证平台下单成功但本地子订单保存失败时立即发起撤销,避免形成孤立可支付订单。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java index 28074eaa..23d23279 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java @@ -25,6 +25,8 @@ import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import com.alipay.api.response.AlipayTradeCancelResponse; +import com.alipay.api.response.AlipayTradeQueryResponse; import org.jasypt.encryption.StringEncryptor; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -148,7 +150,7 @@ class ThirdAggregateOrderPayServiceImplTest { lenient().when(remoteTenantService.list()).thenReturn(R.ok(List.of(tenant))); request = new ThirdAggregateOrderCodePayDto(); - request.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY); + request.setBizType(ThirdPayOrderBizTypeEnum.XFJ); request.setBizId("order-1001"); request.setMchId(88L); request.setAmount(1234); @@ -234,10 +236,10 @@ class ThirdAggregateOrderPayServiceImplTest { } /** - * 验证第三方查单异常不能让已到期会话永久停留在五秒高频轮询队列。 + * 验证第三方查单异常必须保留原会话状态,由任务表使用同一任务键退避重试。 */ @Test - void expiredSessionShouldBecomeExpiredWhenAlipayQueryFails() { + void expiredSessionShouldRemainPendingWhenAlipayQueryFails() { ThirdAggregatePaySessionEntity session = reusableSession(); session.setStatus(ThirdAggregatePaySessionStatusEnum.PAYING.getType()); session.setSelectedPlatform(AppPlusPlatformTypeEnum.ALI_PAY.getType()); @@ -256,14 +258,16 @@ class ThirdAggregateOrderPayServiceImplTest { Assertions.assertThrows(RuntimeException.class, () -> service.processPendingSession(session.getId())); - verify(aggregatePaySessionService).markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.EXPIRED); + verify(aggregatePaySessionService, never()).markTerminal(any(), any()); + verify(aggregateAliPayService).query(payOrder.getPayNo(), payOrder.getMchPaySettingJson()); + verify(aggregateAliPayService, never()).cancel(anyString(), anyString()); } /** - * 验证超过一小时的会话从任何处理入口进入时都只做本地关闭,不访问支付宝或微信。 + * 验证超过一小时但已绑定渠道单的会话仍先查单,并仅在渠道确认撤销后更新本地订单。 */ @Test - void historicalSessionShouldCloseLocallyWithoutQueryingPaymentPlatform() { + void historicalSessionWithChannelOrderShouldQueryAndCancelPaymentPlatform() { ThirdAggregatePaySessionEntity session = reusableSession(); session.setStatus(ThirdAggregatePaySessionStatusEnum.PAYING.getType()); session.setSelectedPlatform(AppPlusPlatformTypeEnum.ALI_PAY.getType()); @@ -274,22 +278,34 @@ class ThirdAggregateOrderPayServiceImplTest { payOrder.setId(102L); payOrder.setPayNo(session.getPayNo()); payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType()); + payOrder.setMchPaySettingJson("{}"); + AlipayTradeQueryResponse queryResponse = new AlipayTradeQueryResponse(); + queryResponse.setCode("10000"); + queryResponse.setTradeStatus("WAIT_BUYER_PAY"); + AlipayTradeCancelResponse cancelResponse = new AlipayTradeCancelResponse(); + cancelResponse.setCode("10000"); + cancelResponse.setAction("close"); + cancelResponse.setRetryFlag("N"); when(aggregatePaySessionService.getById(session.getId())).thenReturn(session); when(thirdPayOrderService.getByPayNo(session.getPayNo())).thenReturn(payOrder); + when(aggregateAliPayService.query(payOrder.getPayNo(), payOrder.getMchPaySettingJson())) + .thenReturn(queryResponse); + when(aggregateAliPayService.cancel(payOrder.getPayNo(), payOrder.getMchPaySettingJson())) + .thenReturn(cancelResponse); service.processPendingSession(session.getId()); - verify(aggregatePaySessionService).markTerminal( - session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED); - verify(thirdPayOrderService).update(any(Wrapper.class)); - verifyNoInteractions(aggregateAliPayService, aggregateWechatPayService); + verify(aggregateAliPayService).query(payOrder.getPayNo(), payOrder.getMchPaySettingJson()); + verify(aggregateAliPayService).cancel(payOrder.getPayNo(), payOrder.getMchPaySettingJson()); + verify(thirdPayOrderService).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + verifyNoInteractions(aggregateWechatPayService); } /** - * 验证一小时后到达的成功回调只保留子订单事实,不把历史聚合会话恢复为支付成功。 + * 验证一小时后到达且已经验签的成功回调仍同步真实扣款事实,不受本地超时状态覆盖。 */ @Test - void lateSuccessfulCallbackShouldNotRecoverHistoricalSession() { + void lateSuccessfulCallbackShouldRecoverHistoricalSession() { ThirdAggregatePaySessionEntity session = reusableSession(); session.setStatus(ThirdAggregatePaySessionStatusEnum.EXPIRED.getType()); session.setPayNo("late-success-pay-no"); @@ -300,13 +316,11 @@ class ThirdAggregateOrderPayServiceImplTest { payOrder.setPayNo(session.getPayNo()); payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); when(aggregatePaySessionService.getByPayNo(payOrder.getPayNo())).thenReturn(session); - when(thirdPayOrderService.getByPayNo(payOrder.getPayNo())).thenReturn(payOrder); Assertions.assertTrue(service.syncAggregateChildOrder(payOrder)); - verify(aggregatePaySessionService).markTerminal( - session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED); - verify(aggregatePaySessionService, never()).syncFromPayOrder(any()); + verify(aggregatePaySessionService).syncFromPayOrder(payOrder); + verify(aggregatePaySessionService, never()).markTerminal(any(), any()); verifyNoInteractions(aggregateAliPayService, aggregateWechatPayService); } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java index 0cc2edd0..4a2ab590 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java @@ -4,21 +4,44 @@ import cn.hutool.core.date.DatePattern; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.json.JSONUtil; import com.alipay.api.AlipayClient; +import com.alipay.api.domain.AlipayTradeAppPayModel; +import com.alipay.api.domain.AlipayTradeOrderSettleModel; +import com.alipay.api.domain.AlipayTradeOrderSettleQueryModel; import com.alipay.api.domain.AlipayTradePrecreateModel; +import com.alipay.api.domain.AlipayTradeRefundModel; +import com.alipay.api.domain.AlipayTradeSettleConfirmModel; +import com.alipay.api.domain.OpenApiRoyaltyDetailInfoPojo; +import com.alipay.api.domain.RoyaltyDetail; +import com.alipay.api.request.AlipayTradeAppPayRequest; +import com.alipay.api.request.AlipayTradeFastpayRefundQueryRequest; +import com.alipay.api.request.AlipayTradeOrderSettleRequest; +import com.alipay.api.request.AlipayTradeOrderSettleQueryRequest; import com.alipay.api.request.AlipayTradePrecreateRequest; +import com.alipay.api.request.AlipayTradeRefundRequest; +import com.alipay.api.request.AlipayTradeSettleConfirmRequest; +import com.alipay.api.response.AlipayTradeAppPayResponse; +import com.alipay.api.response.AlipayTradeFastpayRefundQueryResponse; +import com.alipay.api.response.AlipayTradeOrderSettleResponse; +import com.alipay.api.response.AlipayTradeOrderSettleQueryResponse; import com.alipay.api.response.AlipayTradePrecreateResponse; +import com.alipay.api.response.AlipayTradeRefundResponse; +import com.alipay.api.response.AlipayTradeSettleConfirmResponse; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdMiniWechatRefundDto; import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService; import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService; +import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; @@ -28,19 +51,26 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; import java.util.HashMap; +import java.util.List; import java.util.Map; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +/** + * 支付宝订单码、App 直付通、结算抽佣及已分账退款的关键协议测试。 + */ @ExtendWith(MockitoExtension.class) class ThirdAppPlusAliPayServiceImplTest { @@ -50,6 +80,12 @@ class ThirdAppPlusAliPayServiceImplTest { @Mock private AlipayClient alipayClient; + /** + * 分账订单服务 Mock,用于保存支付宝确认结算、平台抽佣和退分账快照。 + */ + @Mock + private ThirdFzOrderService thirdFzOrderService; + /** * 聚合会话服务 Mock,用于校验支付宝 JSAPI 回调必须存在严格 pay_no 绑定。 */ @@ -73,13 +109,19 @@ class ThirdAppPlusAliPayServiceImplTest { aliPayConfig.setAlipayPublicKey("test-public-key"); aliPayConfig.setBaseCallbackUrl("https://pay.example.com"); aliPayConfig.setPayCallbackUrl("/third-app-pay/app/pay-callback"); + aliPayConfig.setRefundCallbackUrl("/third-app-pay/app/refund-callback"); + aliPayConfig.setRoyaltyTransIn("2088000000000001"); + aliPayConfig.setRoyaltyTransInType("userId"); + aliPayConfig.setRoyaltyTransInName("资安信平台"); appPlusPayConfig.setAliPayConfig(aliPayConfig); MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting(); aliPaySetting.setAppAuthToken("merchant-auth-token"); + aliPaySetting.setSubMchid("smid-1001"); paySetting = new MchPaySettingVo(); paySetting.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType()); paySetting.setEnable(YesNoEnum.YES.getCode()); + paySetting.setFzRatio(10D); paySetting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); payDto = new ThirdAppPlusPayDto(); @@ -91,6 +133,262 @@ class ThirdAppPlusAliPayServiceImplTest { payDto.setMchId(88L); service = new TestableThirdAppPlusAliPayService(appPlusPayConfig, thirdPayOrderService, alipayClient); ReflectionTestUtils.setField(service, "aggregatePaySessionService", aggregatePaySessionService); + ReflectionTestUtils.setField(service, "thirdFzOrderService", thirdFzOrderService); + } + + /** + * 验证 App 支付把可信 smid、金额和十五分钟有效期写入直付通请求,创建阶段不写支付时间。 + */ + @Test + void appPayShouldCreateDirectPayOrderWithoutMarkingPaymentSuccess() throws Exception { + AlipayTradeAppPayResponse response = new AlipayTradeAppPayResponse(); + response.setBody("signed-alipay-order"); + when(alipayClient.sdkExecute(any(AlipayTradeAppPayRequest.class))).thenReturn(response); + LocalDateTime beforeCreate = LocalDateTime.now(); + + String result = service.appPay(payDto, paySetting); + + Assertions.assertEquals("signed-alipay-order", result); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeAppPayRequest.class); + verify(alipayClient).sdkExecute(requestCaptor.capture()); + AlipayTradeAppPayRequest request = requestCaptor.getValue(); + AlipayTradeAppPayModel model = (AlipayTradeAppPayModel) request.getBizModel(); + Assertions.assertEquals("smid-1001", model.getSubMerchant().getMerchantId()); + Assertions.assertEquals("12.34", model.getTotalAmount()); + LocalDateTime timeExpire = LocalDateTimeUtil.parse(model.getTimeExpire(), DatePattern.NORM_DATETIME_PATTERN); + Assertions.assertFalse(timeExpire.isBefore(beforeCreate.plusMinutes(14).plusSeconds(55))); + Assertions.assertFalse(timeExpire.isAfter(LocalDateTime.now().plusMinutes(15).plusSeconds(5))); + Assertions.assertEquals("merchant-auth-token", request.getTextParams().get("app_auth_token")); + + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).save(orderCaptor.capture()); + ThirdPayOrderEntity savedOrder = orderCaptor.getValue(); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_UNPAID.getType(), savedOrder.getStatus()); + Assertions.assertEquals(88L, savedOrder.getMchId()); + Assertions.assertEquals(YesNoEnum.YES.getCode(), savedOrder.getEnableFz()); + Assertions.assertNull(savedOrder.getPayTime()); + } + + /** + * 验证未配置授权令牌时请求不产生空的 app_auth_token 公共参数。 + */ + @Test + void appPayShouldOmitBlankAppAuthToken() throws Exception { + MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean( + paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + aliPaySetting.setAppAuthToken(null); + paySetting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); + AlipayTradeAppPayResponse response = new AlipayTradeAppPayResponse(); + response.setBody("signed-alipay-order"); + when(alipayClient.sdkExecute(any(AlipayTradeAppPayRequest.class))).thenReturn(response); + + service.appPay(payDto, paySetting); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeAppPayRequest.class); + verify(alipayClient).sdkExecute(requestCaptor.capture()); + Assertions.assertFalse(requestCaptor.getValue().getTextParams().containsKey("app_auth_token")); + } + + /** + * 验证支付宝明确拒绝商户授权时结束直付通分账,不会降级调用普通平台抽佣接口。 + */ + @Test + void directPayShouldFailWithoutRoyaltyFallbackWhenAuthorizationIsRejected() throws Exception { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + ThirdFzOrderEntity fzOrder = pendingFzOrder(payOrder); + AlipayTradeSettleConfirmResponse rejected = new AlipayTradeSettleConfirmResponse(); + rejected.setCode("40004"); + rejected.setSubCode("isv.insufficient-isv-permissions"); + when(alipayClient.execute(any(AlipayTradeSettleConfirmRequest.class))).thenReturn(rejected); + when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.confirmDirectPaySettlementStage(fzOrder, payOrder); + + verify(alipayClient).execute(any(AlipayTradeSettleConfirmRequest.class)); + verify(alipayClient, never()).execute(any(AlipayTradeOrderSettleRequest.class)); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_FAILED.getType(), fzOrder.getStatus()); + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + /** + * 验证确认结算严格先于平台抽佣,并且只有两阶段均成功才写最终分账成功。 + */ + @Test + void directPayShouldConfirmSettlementBeforeRoyalty() throws Exception { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + ThirdFzOrderEntity fzOrder = pendingFzOrder(payOrder); + AlipayTradeSettleConfirmResponse confirmResponse = successSettleConfirmResponse(); + AlipayTradeOrderSettleResponse royaltyResponse = successRoyaltyResponse(); + when(alipayClient.execute(any(AlipayTradeSettleConfirmRequest.class))).thenReturn(confirmResponse); + when(alipayClient.execute(any(AlipayTradeOrderSettleRequest.class))).thenReturn(royaltyResponse); + when(alipayClient.execute(any(AlipayTradeOrderSettleQueryRequest.class))).thenReturn(successRoyaltyQueryResponse()); + when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + Assertions.assertEquals("SUCCESS", service.confirmDirectPaySettlementStage(fzOrder, payOrder)); + Assertions.assertEquals("SUBMITTED", service.submitDirectPayRoyaltyStage(fzOrder, payOrder)); + Assertions.assertEquals("SUCCESS", service.queryDirectPayRoyaltyStage(fzOrder, payOrder)); + + InOrder inOrder = inOrder(alipayClient); + inOrder.verify(alipayClient).execute(any(AlipayTradeSettleConfirmRequest.class)); + inOrder.verify(alipayClient).execute(any(AlipayTradeOrderSettleRequest.class)); + inOrder.verify(alipayClient).execute(any(AlipayTradeOrderSettleQueryRequest.class)); + ArgumentCaptor queryCaptor = + ArgumentCaptor.forClass(AlipayTradeOrderSettleQueryRequest.class); + verify(alipayClient).execute(queryCaptor.capture()); + AlipayTradeOrderSettleQueryModel queryModel = + (AlipayTradeOrderSettleQueryModel) queryCaptor.getValue().getBizModel(); + Assertions.assertEquals("royalty_" + payOrder.getPayNo(), queryModel.getOutRequestNo()); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_SUCCESS.getType(), fzOrder.getStatus()); + Assertions.assertEquals("SUCCESS", JSONUtil.parseObj(fzOrder.getCreateRes()) + .getJSONObject("settleConfirm").getStr("status")); + Assertions.assertEquals("SUCCESS", JSONUtil.parseObj(fzOrder.getCreateRes()) + .getJSONObject("royalty").getStr("status")); + verify(thirdPayOrderService).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + /** + * 验证确认结算遇到系统不确定结果后使用相同 out_request_no 重试。 + */ + @Test + void directPayRetryShouldKeepStableSettleRequestNo() throws Exception { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + ThirdFzOrderEntity fzOrder = pendingFzOrder(payOrder); + AlipayTradeSettleConfirmResponse processing = new AlipayTradeSettleConfirmResponse(); + processing.setCode("40004"); + processing.setSubCode("ACQ.SYSTEM_ERROR"); + when(alipayClient.execute(any(AlipayTradeSettleConfirmRequest.class))) + .thenReturn(processing, successSettleConfirmResponse()); + when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.confirmDirectPaySettlementStage(fzOrder, payOrder); + service.confirmDirectPaySettlementStage(fzOrder, payOrder); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(AlipayTradeSettleConfirmRequest.class); + verify(alipayClient, times(2)).execute(requestCaptor.capture()); + List requests = requestCaptor.getAllValues(); + String firstRequestNo = ((AlipayTradeSettleConfirmModel) requests.get(0).getBizModel()).getOutRequestNo(); + String secondRequestNo = ((AlipayTradeSettleConfirmModel) requests.get(1).getBizModel()).getOutRequestNo(); + Assertions.assertEquals("settle_" + payOrder.getPayNo(), firstRequestNo); + Assertions.assertEquals(firstRequestNo, secondRequestNo); + } + + /** + * 验证旧实例返回的处理中快照只能在数据库仍为待处理时写入,不能整行覆盖并发成功终态。 + */ + @Test + void staleDirectPayPollingShouldNotOverwriteSuccessfulFzOrder() throws Exception { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + ThirdFzOrderEntity staleFzOrder = pendingFzOrder(payOrder); + AlipayTradeSettleConfirmResponse processing = new AlipayTradeSettleConfirmResponse(); + processing.setCode("40004"); + processing.setSubCode("ACQ.SYSTEM_ERROR"); + when(alipayClient.execute(any(AlipayTradeSettleConfirmRequest.class))).thenReturn(processing); + // false 模拟另一实例已经把同一记录推进为 FZ_SUCCESS,当前旧快照的原状态条件不再成立。 + when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(false); + + service.confirmDirectPaySettlementStage(staleFzOrder, payOrder); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdFzOrderEntity.class); + verify(thirdFzOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertNull(updateCaptor.getValue().getStatus()); + verify(thirdFzOrderService, never()).updateById(any(ThirdFzOrderEntity.class)); + verify(alipayClient, never()).execute(any(AlipayTradeOrderSettleRequest.class)); + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + /** + * 验证旧版本已经支付成功但没有支付时间的订单仍能物化分账事实,后续等待时间由任务 Worker 安全兜底。 + */ + @Test + void ensureDirectPayFzOrderShouldRecoverHistoricalOrderWithoutPayTime() { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + payOrder.setPayTime(null); + + ThirdFzOrderEntity result = service.ensureDirectPayFzOrder(payOrder); + + Assertions.assertNotNull(result); + Assertions.assertEquals("fz_" + payOrder.getPayNo(), result.getFzNo()); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_UNPAID.getType(), result.getStatus()); + verify(thirdFzOrderService).save(result); + } + + /** + * 验证已完成平台抽佣的支付宝订单退款携带按退款金额折算的 refund_royalty_parameters。 + */ + @Test + void refundShouldIncludeRoyaltyParametersAfterProfitSharing() throws Exception { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + ThirdFzOrderEntity fzOrder = pendingFzOrder(payOrder); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_SUCCESS.getType()); + fzOrder.setPrice(123); + when(thirdFzOrderService.getByPayNo(payOrder.getPayNo())).thenReturn(fzOrder); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + AlipayTradeRefundResponse response = new AlipayTradeRefundResponse(); + response.setCode("10000"); + response.setBody("{\"code\":\"10000\"}"); + when(alipayClient.execute(any(AlipayTradeRefundRequest.class))).thenReturn(response); + ThirdMiniWechatRefundDto refundDto = new ThirdMiniWechatRefundDto(); + refundDto.setAfterSaleId(77L); + refundDto.setRefundPrice(500); + + boolean result = service.refund(payOrder, refundDto); + + Assertions.assertFalse(result); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeRefundRequest.class); + verify(alipayClient).execute(requestCaptor.capture()); + AlipayTradeRefundModel model = (AlipayTradeRefundModel) requestCaptor.getValue().getBizModel(); + Assertions.assertEquals("refund_77", model.getOutRequestNo()); + Assertions.assertEquals("5.00", model.getRefundAmount()); + Assertions.assertEquals(1, model.getRefundRoyaltyParameters().size()); + OpenApiRoyaltyDetailInfoPojo royalty = model.getRefundRoyaltyParameters().get(0); + Assertions.assertEquals("2088000000000001", royalty.getTransOut()); + Assertions.assertEquals("userId", royalty.getTransOutType()); + Assertions.assertEquals("0.49", royalty.getAmount()); + Assertions.assertTrue(fzOrder.getFzRefundReq().contains("2088000000000001")); + } + + /** + * 验证退款查询找不到原请求时使用已保存的相同退款号和参数重提,受理后继续等待查询终态。 + */ + @Test + void queryRefundShouldRetryMissingRequestWithOriginalIdempotencyKey() throws Exception { + ThirdPayOrderEntity payOrder = refundingOrderWithRequest("refund_77"); + AlipayTradeFastpayRefundQueryResponse missing = new AlipayTradeFastpayRefundQueryResponse(); + missing.setCode("40004"); + missing.setSubCode("ACQ.REFUND_NOT_EXIST"); + when(alipayClient.execute(any(AlipayTradeFastpayRefundQueryRequest.class))).thenReturn(missing); + AlipayTradeRefundResponse accepted = new AlipayTradeRefundResponse(); + accepted.setCode("10000"); + accepted.setBody("{\"code\":\"10000\"}"); + when(alipayClient.execute(any(AlipayTradeRefundRequest.class))).thenReturn(accepted); + + AlipayTradeFastpayRefundQueryResponse result = service.queryRefund(payOrder); + + Assertions.assertNull(result); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeRefundRequest.class); + verify(alipayClient).execute(requestCaptor.capture()); + AlipayTradeRefundModel retryModel = (AlipayTradeRefundModel) requestCaptor.getValue().getBizModel(); + Assertions.assertEquals(payOrder.getPayNo(), retryModel.getOutTradeNo()); + Assertions.assertEquals(payOrder.getRefundNo(), retryModel.getOutRequestNo()); + Assertions.assertEquals("5.00", retryModel.getRefundAmount()); + } + + /** + * 验证退款查询系统错误保持可重试,且不会在未确认请求缺失时重复提交退款。 + */ + @Test + void queryRefundShouldKeepSystemErrorRetryableWithoutResubmitting() throws Exception { + ThirdPayOrderEntity payOrder = refundingOrderWithRequest("refund_78"); + AlipayTradeFastpayRefundQueryResponse processing = new AlipayTradeFastpayRefundQueryResponse(); + processing.setCode("40004"); + processing.setSubCode("ACQ.SYSTEM_ERROR"); + when(alipayClient.execute(any(AlipayTradeFastpayRefundQueryRequest.class))).thenReturn(processing); + + AlipayTradeFastpayRefundQueryResponse result = service.queryRefund(payOrder); + + Assertions.assertSame(processing, result); + verify(alipayClient, never()).execute(any(AlipayTradeRefundRequest.class)); } @Test @@ -279,6 +577,96 @@ class ThirdAppPlusAliPayServiceImplTest { return order; } + /** + * 构造已确认支付成功且包含商户配置快照的直付通支付订单。 + * + * @return 可进入确认结算或退款流程的支付订单 + */ + private ThirdPayOrderEntity paidDirectPayOrder() { + ThirdPayOrderEntity order = unpaidOrder(); + order.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); + order.setThirdPayNo("202600000001"); + order.setPayTime(LocalDateTime.now().minusMinutes(1)); + order.setEnableFz(YesNoEnum.YES.getCode()); + order.setMchPaySettingJson(JSONUtil.toJsonStr(paySetting)); + return order; + } + + /** + * 构造包含稳定退款号和原始退款请求快照的退款中订单。 + * + * @param refundNo 退款幂等请求号 + * @return 可执行支付宝退款查询与安全重提的支付订单 + */ + private ThirdPayOrderEntity refundingOrderWithRequest(String refundNo) { + ThirdPayOrderEntity order = paidDirectPayOrder(); + order.setStatus(ThirdPayOrderStatusEnum.REFUNDING.getType()); + order.setRefundNo(refundNo); + AlipayTradeRefundModel model = new AlipayTradeRefundModel(); + model.setOutTradeNo(order.getPayNo()); + model.setOutRequestNo(refundNo); + model.setRefundAmount("5.00"); + model.setRefundReason("测试退款"); + order.setRefundRep(JSONUtil.toJsonStr(model)); + return order; + } + + /** + * 构造使用稳定分账号的待处理分账记录。 + * + * @param payOrder 对应支付订单 + * @return 待确认结算的分账记录 + */ + private ThirdFzOrderEntity pendingFzOrder(ThirdPayOrderEntity payOrder) { + ThirdFzOrderEntity fzOrder = new ThirdFzOrderEntity(); + fzOrder.setId(payOrder.getId()); + fzOrder.setPayNo(payOrder.getPayNo()); + fzOrder.setFzNo("fz_" + payOrder.getPayNo()); + fzOrder.setPrice(123); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_UNPAID.getType()); + return fzOrder; + } + + /** + * 构造支付宝确认结算成功响应。 + * + * @return SDK 成功响应 + */ + private AlipayTradeSettleConfirmResponse successSettleConfirmResponse() { + AlipayTradeSettleConfirmResponse response = new AlipayTradeSettleConfirmResponse(); + response.setCode("10000"); + response.setMsg("Success"); + return response; + } + + /** + * 构造支付宝平台抽佣成功响应。 + * + * @return SDK 成功响应 + */ + private AlipayTradeOrderSettleResponse successRoyaltyResponse() { + AlipayTradeOrderSettleResponse response = new AlipayTradeOrderSettleResponse(); + response.setCode("10000"); + response.setMsg("Success"); + return response; + } + + /** + * 构造支付宝明确返回抽佣明细成功的查询响应。 + * @return SDK 抽佣查询成功响应 + */ + private AlipayTradeOrderSettleQueryResponse successRoyaltyQueryResponse() { + RoyaltyDetail detail = new RoyaltyDetail(); + detail.setState("SUCCESS"); + detail.setAmount("1.23"); + AlipayTradeOrderSettleQueryResponse response = new AlipayTradeOrderSettleQueryResponse(); + response.setCode("10000"); + response.setMsg("Success"); + response.setOutRequestNo("royalty_" + payDto.getOutTradeNo()); + response.setRoyaltyDetailList(List.of(detail)); + return response; + } + private Map successCallbackParams() { Map params = new HashMap<>(); params.put("app_id", appPlusPayConfig.getAliPayConfig().getAppId()); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java index 53db7cc0..e7ec734d 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java @@ -11,17 +11,20 @@ import com.bsd.pjcloud.app.api.feign.RemoteAppUserService; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.hard.api.feign.RemoteHardOrderService; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdOrderCodePayDto; +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMiniRemotePayDto; import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.service.*; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayStatusVo; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.wechat.ThirdMiniWechatRefundQueryVo; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.feign.RemoteWalletRechargePackageService; @@ -332,6 +335,7 @@ class ThirdAppPlusServiceImplOrderCodePayTest { .thenReturn(queryResponse("TRADE_FINISHED", "alipay-trade-no")); when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); ThirdPayOrderEntity paidOrder = order(ThirdPayOrderStatusEnum.PAY_SUCCESS, unpaidOrder.getExpireTime()); + paidOrder.setIsBizCallback(YesNoEnum.YES.getCode()); when(thirdPayOrderService.getById(unpaidOrder.getId())).thenReturn(paidOrder); service.queryPayResult(unpaidOrder); @@ -465,6 +469,158 @@ class ThirdAppPlusServiceImplOrderCodePayTest { Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_CLOSED.getType(), updateCaptor.getValue().getStatus()); } + /** + * 验证普通微信 App 支付到期后调用 App 关单接口,渠道确认成功才推进本地关闭状态。 + */ + @Test + void queryPayResultShouldCloseExpiredWechatAppOrderAfterChannelConfirmation() { + ThirdPayOrderEntity expiredOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().minusSeconds(1)); + expiredOrder.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType()); + expiredOrder.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY.getType()); + com.wechat.pay.java.service.partnerpayments.app.model.Transaction transaction = + new com.wechat.pay.java.service.partnerpayments.app.model.Transaction(); + transaction.setTradeState(com.wechat.pay.java.service.partnerpayments.app.model.Transaction.TradeStateEnum.NOTPAY); + when(thirdMiniAppWechatService.query(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())) + .thenReturn(transaction); + when(thirdMiniAppWechatService.close(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())) + .thenReturn(true); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.queryPayResult(expiredOrder); + + verify(thirdMiniAppWechatService).close(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson()); + verify(thirdAppPlusWechatNativePayService, never()).close(any(), any()); + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_CLOSED.getType(), updateCaptor.getValue().getStatus()); + } + + /** + * 验证微信 App 关单网络结果未知时保持未支付,等待原任务号下一轮重试。 + */ + @Test + void queryPayResultShouldKeepExpiredWechatAppOrderUnpaidWhenCloseIsUnknown() { + ThirdPayOrderEntity expiredOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().minusSeconds(1)); + expiredOrder.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType()); + expiredOrder.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY.getType()); + com.wechat.pay.java.service.partnerpayments.app.model.Transaction transaction = + new com.wechat.pay.java.service.partnerpayments.app.model.Transaction(); + transaction.setTradeState(com.wechat.pay.java.service.partnerpayments.app.model.Transaction.TradeStateEnum.NOTPAY); + when(thirdMiniAppWechatService.query(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())) + .thenReturn(transaction); + when(thirdMiniAppWechatService.close(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())) + .thenReturn(false); + + service.queryPayResult(expiredOrder); + + verify(thirdMiniAppWechatService).close(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson()); + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + /** + * 验证商城支付以硬件订单归属商户为真源,远程 DTO 商户不一致时拒绝创建支付。 + */ + @Test + void miniPayShouldRejectShopOrderMerchantMismatch() { + ThirdMiniRemotePayDto payRequest = shopPayRequest(99L); + HardOrderEntity hardOrder = hardOrder(88L); + when(remoteHardOrderService.getByIds(List.of(hardOrder.getId()))).thenReturn(R.ok(List.of(hardOrder))); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, + () -> service.miniPay(payRequest.getUserId(), payRequest)); + + Assertions.assertTrue(exception.getMessage().contains("收款商户不一致")); + verify(thirdMiniAppAliPayService, never()).appPay(any(), any()); + } + + /** + * 验证商城支付缺少直付通 smid 时在第三方下单前失败。 + */ + @Test + void miniPayShouldRejectMissingDirectPaySmid() { + ThirdMiniRemotePayDto payRequest = shopPayRequest(88L); + HardOrderEntity hardOrder = hardOrder(88L); + MchManageVo shopMerchant = enabledAliPayMerchant(); + MchPaySettingVo setting = shopMerchant.getPaySettingList().get(0); + setting.setSettingJson("{}"); + when(remoteHardOrderService.getByIds(List.of(hardOrder.getId()))).thenReturn(R.ok(List.of(hardOrder))); + when(remoteMchService.getById(88L)).thenReturn(R.ok(shopMerchant)); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, + () -> service.miniPay(payRequest.getUserId(), payRequest)); + + Assertions.assertTrue(exception.getMessage().contains("二级商户标识未配置")); + verify(thirdMiniAppAliPayService, never()).appPay(any(), any()); + } + + /** + * 验证商城支付只把硬件订单持久化的收款商户写入第三方支付 DTO。 + */ + @Test + void miniPayShouldUseTrustedShopOrderMerchant() { + ThirdMiniRemotePayDto payRequest = shopPayRequest(88L); + HardOrderEntity hardOrder = hardOrder(88L); + MchManageVo shopMerchant = enabledAliPayMerchant(); + when(remoteHardOrderService.getByIds(List.of(hardOrder.getId()))).thenReturn(R.ok(List.of(hardOrder))); + when(remoteMchService.getById(88L)).thenReturn(R.ok(shopMerchant)); + when(thirdMiniAppAliPayService.appPay(any(), any())).thenReturn("signed-order"); + + service.miniPay(payRequest.getUserId(), payRequest); + + ArgumentCaptor payDtoCaptor = ArgumentCaptor.forClass(ThirdAppPlusPayDto.class); + verify(thirdMiniAppAliPayService).appPay(payDtoCaptor.capture(), any()); + Assertions.assertEquals(88L, payDtoCaptor.getValue().getMchId()); + Assertions.assertEquals("smid-1001", payDtoCaptor.getValue().getSubMchid()); + Assertions.assertEquals(hardOrder.getPayPrice(), payDtoCaptor.getValue().getAmount()); + } + + /** + * 验证微信退款 PROCESSING 不提前推进本地订单终态。 + */ + @Test + void queryRefundResultShouldKeepWechatProcessingState() { + ThirdPayOrderEntity refundingOrder = refundingOrder(201L, "refund-processing"); + ThirdMiniWechatRefundQueryVo query = new ThirdMiniWechatRefundQueryVo(); + query.setRefund_status("PROCESSING"); + when(thirdMiniAppWechatService.refundQuery(refundingOrder.getPayNo())).thenReturn(query); + + service.queryRefundResult(refundingOrder); + + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + /** + * 验证微信 SUCCESS、CLOSED、ABNORMAL 分别映射退款成功或失败终态。 + */ + @Test + void queryRefundResultShouldMapWechatTerminalStates() { + ThirdPayOrderEntity successOrder = refundingOrder(202L, "refund-success"); + ThirdPayOrderEntity closedOrder = refundingOrder(203L, "refund-closed"); + ThirdPayOrderEntity abnormalOrder = refundingOrder(204L, "refund-abnormal"); + ThirdMiniWechatRefundQueryVo success = new ThirdMiniWechatRefundQueryVo(); + success.setRefund_status("SUCCESS"); + ThirdMiniWechatRefundQueryVo closed = new ThirdMiniWechatRefundQueryVo(); + closed.setRefund_status("CLOSED"); + ThirdMiniWechatRefundQueryVo abnormal = new ThirdMiniWechatRefundQueryVo(); + abnormal.setRefund_status("ABNORMAL"); + when(thirdMiniAppWechatService.refundQuery(any())).thenReturn(success, closed, abnormal); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + ThirdPayOrderEntity latestSuccess = refundingOrder(successOrder.getId(), successOrder.getRefundNo()); + latestSuccess.setStatus(ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType()); + latestSuccess.setIsBizCallback(YesNoEnum.YES.getCode()); + when(thirdPayOrderService.getById(successOrder.getId())).thenReturn(latestSuccess); + + service.queryRefundResult(successOrder); + service.queryRefundResult(closedOrder); + service.queryRefundResult(abnormalOrder); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService, org.mockito.Mockito.times(3)).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType(), updateCaptor.getAllValues().get(0).getStatus()); + Assertions.assertEquals(ThirdPayOrderStatusEnum.REFUND_FAILED.getType(), updateCaptor.getAllValues().get(1).getStatus()); + Assertions.assertEquals(ThirdPayOrderStatusEnum.REFUND_FAILED.getType(), updateCaptor.getAllValues().get(2).getStatus()); + } + private void mockNoExistingOrder() { when(thirdPayOrderService.count(any(Wrapper.class))).thenReturn(0L); when(thirdPayOrderService.getOne(any(Wrapper.class))).thenReturn(null); @@ -473,9 +629,11 @@ class ThirdAppPlusServiceImplOrderCodePayTest { private MchManageVo enabledAliPayMerchant() { MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting(); aliPaySetting.setAppAuthToken("merchant-auth-token"); + aliPaySetting.setSubMchid("smid-1001"); MchPaySettingVo setting = new MchPaySettingVo(); setting.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType()); setting.setEnable(YesNoEnum.YES.getCode()); + setting.setFzRatio(10D); setting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); MchManageVo result = new MchManageVo(); result.setId(88L); @@ -483,6 +641,55 @@ class ThirdAppPlusServiceImplOrderCodePayTest { return result; } + /** + * 构造服务端商城支付请求,mchId 只用于与硬件订单真源做一致性校验。 + * + * @param mchId 远程 DTO 携带的商户 ID + * @return 商城支付请求 + */ + private ThirdMiniRemotePayDto shopPayRequest(Long mchId) { + ThirdMiniRemotePayDto payRequest = new ThirdMiniRemotePayDto(); + payRequest.setUserId(10L); + payRequest.setMchId(mchId); + payRequest.setBizId("7001"); + payRequest.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY); + payRequest.setMiniPlatformType(AppPlusPlatformTypeEnum.ALI_PAY); + payRequest.setAmount(1234); + return payRequest; + } + + /** + * 构造带可信用户、金额和收款商户的硬件商城订单。 + * + * @param mchId 数据库订单归属商户 ID + * @return 硬件商城订单 + */ + private HardOrderEntity hardOrder(Long mchId) { + HardOrderEntity hardOrder = new HardOrderEntity(); + hardOrder.setId(7001L); + hardOrder.setUserId(10L); + hardOrder.setMerchantId(mchId); + hardOrder.setPayPrice(1234); + return hardOrder; + } + + /** + * 构造微信退款中的本地支付订单。 + * + * @param id 支付订单主键 + * @param refundNo 稳定退款单号 + * @return 退款轮询订单 + */ + private ThirdPayOrderEntity refundingOrder(Long id, String refundNo) { + ThirdPayOrderEntity order = new ThirdPayOrderEntity(); + order.setId(id); + order.setPayNo("pay-" + id); + order.setRefundNo(refundNo); + order.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType()); + order.setStatus(ThirdPayOrderStatusEnum.REFUNDING.getType()); + return order; + } + private MchManageVo enabledWechatMerchant() { MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); wechatPaySetting.setSubMchid("1900000109"); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java new file mode 100644 index 00000000..ebb8df38 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java @@ -0,0 +1,199 @@ +package com.bsd.pjcloud.thirdparty.service.impl.appplus; + +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdFzOrderMapper; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayOrderMapper; +import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService; +import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer; +import com.bsd.pjcloud.thirdparty.service.impl.appplus.fz.ThirdAppPlusWechatFzServiceImpl; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.wechat.pay.java.service.profitsharing.ProfitsharingService; +import com.wechat.pay.java.service.profitsharing.model.OrderStatus; +import com.wechat.pay.java.service.profitsharing.model.OrdersEntity; +import com.wechat.pay.java.service.profitsharing.model.QueryOrderRequest; +import com.wechat.pay.java.service.profitsharing.model.QueryReturnOrderRequest; +import com.wechat.pay.java.service.profitsharing.model.ReturnOrderStatus; +import com.wechat.pay.java.service.profitsharing.model.ReturnOrdersEntity; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 微信分账与退分账数据库轮询状态映射测试。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdAppPlusWechatFzServiceImplTest { + + /** 微信平台配置 Mock,仅补齐构造依赖。 */ + @Mock + private AppPlusPayConfig appPlusPayConfig; + + /** 微信分账 SDK 服务 Mock。 */ + @Mock + private ProfitsharingService profitsharingService; + + /** 分账订单服务 Mock,用于捕获带原状态条件的更新。 */ + @Mock + private ThirdFzOrderService fzOrderService; + + /** 支付订单服务 Mock,用于读取配置快照并更新最终抽佣标记。 */ + @Mock + private ThirdPayOrderService thirdPayOrderService; + + /** 支付订单 Mapper Mock,仅补齐构造依赖。 */ + @Mock + private ThirdPayOrderMapper thirdPayOrderMapper; + + /** 分账订单 Mapper Mock,仅补齐构造依赖。 */ + @Mock + private ThirdFzOrderMapper thirdFzOrderMapper; + + /** 支付任务写入器 Mock,用于补齐微信退分账实时恢复任务依赖。 */ + @Mock + private ThirdPaymentTaskEnqueuer paymentTaskEnqueuer; + + /** 被测微信分账服务。 */ + @InjectMocks + private ThirdAppPlusWechatFzServiceImpl service; + + /** 公共支付订单快照。 */ + private ThirdPayOrderEntity payOrder; + + /** + * 准备微信子商户配置快照。 + */ + @BeforeEach + void setUp() { + MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); + wechatPaySetting.setSubMchid("wx-sub-1001"); + MchPaySettingEntity setting = new MchPaySettingEntity(); + setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); + payOrder = new ThirdPayOrderEntity(); + payOrder.setId(1L); + payOrder.setPayNo("pay-1001"); + payOrder.setThirdPayNo("wx-transaction-1001"); + payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting)); + } + + /** + * 验证微信 PROCESSING 只刷新快照,不提前标记分账成功。 + */ + @Test + void pollFzOrderShouldKeepProcessingState() { + ThirdFzOrderEntity fzOrder = fzOrder(1L, ThirdFzOrderStatusEnum.FZ_UNPAID); + OrdersEntity response = new OrdersEntity(); + response.setState(OrderStatus.PROCESSING); + when(thirdPayOrderService.getByPayNo(fzOrder.getPayNo())).thenReturn(payOrder); + when(profitsharingService.queryOrder(any(QueryOrderRequest.class))).thenReturn(response); + + service.pollFzOrder(fzOrder); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdFzOrderEntity.class); + verify(fzOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertNull(updateCaptor.getValue().getStatus()); + Assertions.assertEquals("PROCESSING", JSONUtil.parseObj(updateCaptor.getValue().getCreateRes()) + .getJSONObject("royalty").getStr("status")); + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + /** + * 验证微信 FINISHED 才同时推进分账记录和支付订单最终抽佣标记。 + */ + @Test + void pollFzOrderShouldMarkSuccessOnlyWhenFinished() { + ThirdFzOrderEntity fzOrder = fzOrder(2L, ThirdFzOrderStatusEnum.FZ_UNPAID); + OrdersEntity response = new OrdersEntity(); + response.setState(OrderStatus.FINISHED); + when(thirdPayOrderService.getByPayNo(fzOrder.getPayNo())).thenReturn(payOrder); + when(profitsharingService.queryOrder(any(QueryOrderRequest.class))).thenReturn(response); + when(fzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.pollFzOrder(fzOrder); + + ArgumentCaptor fzUpdateCaptor = ArgumentCaptor.forClass(ThirdFzOrderEntity.class); + verify(fzOrderService).update(fzUpdateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_SUCCESS.getType(), fzUpdateCaptor.getValue().getStatus()); + ArgumentCaptor payUpdateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).update(payUpdateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(YesNoEnum.YES.getCode(), payUpdateCaptor.getValue().getIsFzSuccess()); + Assertions.assertEquals(fzOrder.getPrice(), payUpdateCaptor.getValue().getFzPrice()); + } + + /** + * 验证退分账 PROCESSING 保持原状态,SUCCESS 和 FAILED 分别进入对应终态。 + */ + @Test + void pollFzRefundOrderShouldMapWechatReturnStatuses() { + ThirdFzOrderEntity processing = fzOrder(11L, ThirdFzOrderStatusEnum.FZ_REFUNDING); + ThirdFzOrderEntity success = fzOrder(12L, ThirdFzOrderStatusEnum.FZ_REFUNDING); + ThirdFzOrderEntity failed = fzOrder(13L, ThirdFzOrderStatusEnum.FZ_REFUNDING); + ReturnOrdersEntity processingResponse = returnOrder(ReturnOrderStatus.PROCESSING); + ReturnOrdersEntity successResponse = returnOrder(ReturnOrderStatus.SUCCESS); + ReturnOrdersEntity failedResponse = returnOrder(ReturnOrderStatus.FAILED); + when(thirdPayOrderService.getByPayNo(any())).thenReturn(payOrder); + when(profitsharingService.queryReturnOrder(any(QueryReturnOrderRequest.class))) + .thenReturn(processingResponse, successResponse, failedResponse); + + service.pollFzRefundOrder(processing); + service.pollFzRefundOrder(success); + service.pollFzRefundOrder(failed); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdFzOrderEntity.class); + verify(fzOrderService, org.mockito.Mockito.times(3)).update(updateCaptor.capture(), any(Wrapper.class)); + List updates = updateCaptor.getAllValues(); + Assertions.assertNull(updates.get(0).getStatus()); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType(), updates.get(1).getStatus()); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType(), updates.get(2).getStatus()); + } + + /** + * 构造稳定分账号和退分账号的分账记录。 + * + * @param id 分账记录主键 + * @param status 本地分账状态 + * @return 待轮询分账记录 + */ + private ThirdFzOrderEntity fzOrder(Long id, ThirdFzOrderStatusEnum status) { + ThirdFzOrderEntity fzOrder = new ThirdFzOrderEntity(); + fzOrder.setId(id); + fzOrder.setPayNo(payOrder.getPayNo()); + fzOrder.setFzNo("fz-pay-1001"); + fzOrder.setFzRefundNo("fz-refund-pay-1001"); + fzOrder.setPrice(123); + fzOrder.setStatus(status.getType()); + return fzOrder; + } + + /** + * 构造指定微信退分账状态的查询响应。 + * + * @param status 微信退分账状态 + * @return 查询响应 + */ + private ReturnOrdersEntity returnOrder(ReturnOrderStatus status) { + ReturnOrdersEntity response = new ReturnOrdersEntity(); + response.setResult(status); + response.setOutOrderNo("fz-pay-1001"); + response.setOutReturnNo("fz-refund-pay-1001"); + return response; + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java index a436cf11..59ae18b4 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java @@ -29,6 +29,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; import java.util.Map; @@ -83,6 +84,7 @@ class ThirdAppPlusWechatServiceImplPayCallbackTest { @BeforeEach void setUp() { + ReflectionTestUtils.setField(service, "aggregatePaySessionService", aggregatePaySessionService); AppPlusPayConfig.WechatConfig wechatConfig = new AppPlusPayConfig.WechatConfig(); wechatConfig.setAppPlatformAppId("wx-service-app-id"); wechatConfig.setGzhAppId("wx-service-jsapi-appid"); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java new file mode 100644 index 00000000..379dd4bb --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java @@ -0,0 +1,276 @@ +package com.bsd.pjcloud.thirdparty.service.impl.appplus; + +import cn.binarywang.wx.miniapp.api.WxMaService; +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto; +import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdMiniWechatRefundDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import com.bsd.pjcloud.thirdparty.service.impl.appplus.fz.lifecycle.ThirdAppPlusWechatFzLifecycle; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.wechat.ThirdMiniWechatRefundQueryVo; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import com.wechat.pay.java.core.notification.NotificationParser; +import com.wechat.pay.java.service.partnerpayments.app.AppServiceExtension; +import com.wechat.pay.java.service.partnerpayments.app.model.CloseOrderRequest; +import com.wechat.pay.java.service.partnerpayments.app.model.PrepayRequest; +import com.wechat.pay.java.service.partnerpayments.app.model.PrepayWithRequestPaymentResponse; +import com.wechat.pay.java.service.refund.RefundService; +import com.wechat.pay.java.service.refund.model.Amount; +import com.wechat.pay.java.service.refund.model.CreateRequest; +import com.wechat.pay.java.service.refund.model.QueryByOutRefundNoRequest; +import com.wechat.pay.java.service.refund.model.Refund; +import com.wechat.pay.java.service.refund.model.Status; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.OffsetDateTime; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * 微信普通退款查单协议测试,确保服务商子商户快照和外部状态完整映射。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdAppPlusWechatServiceImplRefundTest { + + /** 微信支付应用配置 Mock。 */ + @Mock + private AppPlusPayConfig appPlusPayConfig; + + /** 微信小程序能力 Mock,仅补齐被测服务构造依赖。 */ + @Mock + private WxMaService wxMaService; + + /** 支付订单服务 Mock,提供退款中的订单快照。 */ + @Mock + private ThirdPayOrderService thirdPayOrderService; + + /** 微信服务商 App 支付服务 Mock,仅补齐构造依赖。 */ + @Mock + private AppServiceExtension appServiceExtension; + + /** 微信退款服务 Mock,用于捕获退款查询请求。 */ + @Mock + private RefundService refundService; + + /** 微信回调解析器 Mock,仅补齐构造依赖。 */ + @Mock + private NotificationParser notificationParser; + + /** 微信分账生命周期 Mock,仅补齐构造依赖。 */ + @Mock + private ThirdAppPlusWechatFzLifecycle appPlusWechatFzLifecycle; + + /** 被测微信 App 支付服务。 */ + @InjectMocks + private ThirdAppPlusWechatServiceImpl service; + + /** + * 验证 SUCCESS、CLOSED、ABNORMAL、PROCESSING 均按微信原始编码返回上层状态机。 + */ + @Test + void refundQueryShouldMapAllWechatStatusesAndUseSnapshotSubMerchant() { + ThirdPayOrderEntity payOrder = refundingOrder(); + when(thirdPayOrderService.getByPayNo(payOrder.getPayNo())).thenReturn(payOrder); + + for (Status status : Status.values()) { + Refund refund = refund(status); + when(refundService.queryByOutRefundNo(any(QueryByOutRefundNoRequest.class))).thenReturn(refund); + + ThirdMiniWechatRefundQueryVo result = service.refundQuery(payOrder.getPayNo()); + + Assertions.assertNotNull(result); + Assertions.assertEquals(status.name(), result.getRefund_status()); + Assertions.assertEquals(500L, result.getRefund_fee()); + Assertions.assertEquals(1234L, result.getTotal_fee()); + } + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(QueryByOutRefundNoRequest.class); + verify(refundService, org.mockito.Mockito.times(Status.values().length)) + .queryByOutRefundNo(requestCaptor.capture()); + Assertions.assertTrue(requestCaptor.getAllValues().stream() + .allMatch(request -> "refund-pay-1001".equals(request.getOutRefundNo()) + && "wx-sub-1001".equals(request.getSubMchid()))); + } + + /** + * 验证微信退款创建同步返回只对 SUCCESS、CLOSED、ABNORMAL 推进终态,PROCESSING 继续等待回调或轮询。 + */ + @Test + void refundShouldApplySynchronousTerminalStatusWithoutAdvancingProcessing() { + AppPlusPayConfig.WechatConfig config = new AppPlusPayConfig.WechatConfig(); + config.setBaseCallbackUrl("https://pay.example.com"); + config.setRefundCallbackUrl("/refund-callback"); + when(appPlusPayConfig.getWechatConfig()).thenReturn(config); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + for (Status status : Status.values()) { + ThirdPayOrderEntity payOrder = paidRefundableOrder(); + when(refundService.create(any(CreateRequest.class))).thenReturn(refund(status)); + when(thirdPayOrderService.getByPayNo(payOrder.getPayNo())).thenReturn(payOrder); + ThirdMiniWechatRefundDto dto = new ThirdMiniWechatRefundDto(); + dto.setPayNo(payOrder.getPayNo()); + dto.setRefundPrice(500); + + service.refund(payOrder, dto); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService, org.mockito.Mockito.times(2)) + .update(updateCaptor.capture(), any(Wrapper.class)); + ThirdPayOrderEntity responseUpdate = updateCaptor.getAllValues().get(1); + if (status == Status.SUCCESS) { + Assertions.assertEquals(ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType(), responseUpdate.getStatus()); + } else if (status == Status.CLOSED || status == Status.ABNORMAL) { + Assertions.assertEquals(ThirdPayOrderStatusEnum.REFUND_FAILED.getType(), responseUpdate.getStatus()); + } else { + Assertions.assertNull(responseUpdate.getStatus()); + } + clearInvocations(thirdPayOrderService, refundService); + } + } + + /** + * 验证非法微信分账比例在创建渠道支付前被拒绝,不会留下无法完成抽佣的已支付交易。 + */ + @Test + void appPayShouldRejectInvalidProfitSharingRatioBeforeCreatingOrder() { + MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); + wechatPaySetting.setSubMchid("wx-sub-1001"); + MchPaySettingVo setting = new MchPaySettingVo(); + setting.setFzRatio(101D); + setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); + ThirdAppPlusPayDto dto = new ThirdAppPlusPayDto(); + dto.setAmount(1000); + dto.setOutTradeNo("pay-invalid-ratio"); + dto.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, + () -> service.appPay(dto, setting)); + + Assertions.assertTrue(exception.getMessage().contains("不超过100%")); + verifyNoInteractions(appServiceExtension); + verify(thirdPayOrderService, org.mockito.Mockito.never()).save(any(ThirdPayOrderEntity.class)); + } + + /** + * 验证微信 App 预下单把本地保存的十五分钟截止时间同步传给渠道,避免本地和微信订单生命周期分叉。 + */ + @Test + void appPayShouldSendSameExpireTimeToWechatAndPersistedOrder() { + AppPlusPayConfig.WechatConfig config = new AppPlusPayConfig.WechatConfig(); + config.setAppPlatformAppId("wx-app-id"); + config.setMerchantId("wx-service-mch"); + config.setBaseCallbackUrl("https://pay.example.com"); + config.setPayCallbackUrl("/pay-callback"); + when(appPlusPayConfig.getWechatConfig()).thenReturn(config); + PrepayWithRequestPaymentResponse response = new PrepayWithRequestPaymentResponse(); + response.setPrepayId("prepay-id"); + when(appServiceExtension.prepayWithRequestPayment(any(PrepayRequest.class), any())).thenReturn(response); + + MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); + wechatPaySetting.setSubMchid("wx-sub-1001"); + MchPaySettingVo setting = new MchPaySettingVo(); + setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); + ThirdAppPlusPayDto dto = new ThirdAppPlusPayDto(); + dto.setAmount(1234); + dto.setOutTradeNo("pay-expire-1001"); + dto.setBizId("order-1001"); + dto.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY); + dto.setMchId(88L); + + service.appPay(dto, setting); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PrepayRequest.class); + verify(appServiceExtension).prepayWithRequestPayment(requestCaptor.capture(), any()); + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).save(orderCaptor.capture()); + OffsetDateTime channelExpireTime = OffsetDateTime.parse(requestCaptor.getValue().getTimeExpire()); + Assertions.assertEquals(orderCaptor.getValue().getExpireTime().withNano(0), channelExpireTime.toLocalDateTime()); + Assertions.assertTrue(orderCaptor.getValue().getExpireTime().isAfter(java.time.LocalDateTime.now().plusMinutes(14))); + } + + /** + * 验证微信 App 关单只使用订单配置快照中的子商户号,且 SDK 异常不能被误判为关单成功。 + */ + @Test + void closeShouldUseSnapshotSubMerchantAndKeepUnknownResultRetryable() { + AppPlusPayConfig.WechatConfig config = new AppPlusPayConfig.WechatConfig(); + config.setMerchantId("wx-service-mch"); + when(appPlusPayConfig.getWechatConfig()).thenReturn(config); + ThirdPayOrderEntity order = refundingOrder(); + + Assertions.assertTrue(service.close(order.getPayNo(), order.getMchPaySettingJson())); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(CloseOrderRequest.class); + verify(appServiceExtension).closeOrder(requestCaptor.capture()); + Assertions.assertEquals(order.getPayNo(), requestCaptor.getValue().getOutTradeNo()); + Assertions.assertEquals("wx-service-mch", requestCaptor.getValue().getSpMchid()); + Assertions.assertEquals("wx-sub-1001", requestCaptor.getValue().getSubMchid()); + + doThrow(new RuntimeException("network")).when(appServiceExtension).closeOrder(any(CloseOrderRequest.class)); + Assertions.assertFalse(service.close(order.getPayNo(), order.getMchPaySettingJson())); + } + + /** + * 构造包含微信子商户快照的退款中订单。 + * + * @return 退款查询所需支付订单 + */ + private ThirdPayOrderEntity refundingOrder() { + MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); + wechatPaySetting.setSubMchid("wx-sub-1001"); + MchPaySettingEntity setting = new MchPaySettingEntity(); + setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); + ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity(); + payOrder.setId(1L); + payOrder.setPayNo("pay-1001"); + payOrder.setRefundNo("refund-pay-1001"); + payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting)); + return payOrder; + } + + /** + * 构造可发起普通退款的已支付微信订单。 + * + * @return 包含金额、状态和子商户配置快照的支付订单 + */ + private ThirdPayOrderEntity paidRefundableOrder() { + ThirdPayOrderEntity payOrder = refundingOrder(); + payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); + payOrder.setPrice(1234); + return payOrder; + } + + /** + * 构造指定状态的微信退款查询响应。 + * + * @param status 微信退款状态 + * @return 包含订单号和金额的退款响应 + */ + private Refund refund(Status status) { + Amount amount = new Amount(); + amount.setRefund(500L); + amount.setTotal(1234L); + Refund refund = new Refund(); + refund.setOutTradeNo("pay-1001"); + refund.setTransactionId("wx-transaction-1001"); + refund.setStatus(status); + refund.setAmount(amount); + return refund; + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImplTest.java new file mode 100644 index 00000000..ff87a0b9 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImplTest.java @@ -0,0 +1,204 @@ +package com.bsd.pjcloud.thirdparty.service.impl.pay; + +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.entity.SysUser; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskStatusEnum; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayOrderDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPaymentTaskEntity; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayOrderMapper; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskMapper; +import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayOrderVo; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +/** + * 支付订单管理端投影测试,覆盖商户归属、直付通 smid、分账阶段和敏感配置脱敏。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdPayOrderServiceImplTest { + + /** 支付订单 Mapper Mock,返回固定分页。 */ + @Mock + private ThirdPayOrderMapper thirdPayOrderMapper; + + /** 用户远程服务 Mock,补充支付用户名称。 */ + @Mock + private RemoteUserService remoteUserService; + + /** 商户远程服务 Mock,补充可信商户名称。 */ + @Mock + private RemoteMchService remoteMchService; + + /** 分账订单服务 Mock,返回结构化阶段快照。 */ + @Mock + private ThirdFzOrderService thirdFzOrderService; + + /** 支付任务 Mapper Mock,用于验证同一支付订单的运营处置任务选择规则。 */ + @Mock + private ThirdPaymentTaskMapper thirdPaymentTaskMapper; + + /** 被测支付订单服务。 */ + @InjectMocks + private ThirdPayOrderServiceImpl service; + + /** + * 将 MyBatis-Plus 基类 Mapper 指向当前 Mock。 + */ + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "baseMapper", thirdPayOrderMapper); + ReflectionTestUtils.setField(service, "thirdPaymentTaskMapper", thirdPaymentTaskMapper); + lenient().when(thirdPaymentTaskMapper.selectList(any(Wrapper.class))).thenReturn(List.of()); + } + + /** + * 支付宝直付通订单应独立展示确认结算与平台抽佣,且浏览器不能获得完整商户配置快照。 + */ + @Test + void scopePageShouldProjectDirectPayStagesAndRemoveSettingSnapshot() { + ThirdPayOrderVo order = alipayOrder(); + Page page = new Page<>(1, 10); + page.setRecords(List.of(order)); + when(thirdPayOrderMapper.scopePage(any(Page.class), any(ThirdPayOrderDto.class), isNull())).thenReturn(page); + + SysUser user = new SysUser(); + user.setUserId(order.getUserId()); + user.setName("测试用户"); + when(remoteUserService.getInnerUserById(List.of(order.getUserId()))).thenReturn(R.ok(List.of(user))); + MchManageVo merchant = new MchManageVo(); + merchant.setId(order.getMchId()); + merchant.setName("测试商户"); + when(remoteMchService.getByIds(List.of(order.getMchId()))).thenReturn(R.ok(List.of(merchant))); + when(thirdFzOrderService.list(any(Wrapper.class))).thenReturn(List.of(fzOrder(order.getPayNo()))); + + ThirdPayOrderVo result = service.scopePage(new Page<>(1, 10), new ThirdPayOrderDto(), null).getRecords().get(0); + + Assertions.assertEquals("测试商户", result.getMchName()); + Assertions.assertEquals("smid-1001", result.getSmid()); + Assertions.assertEquals("SUCCESS", result.getSettleConfirmStatus()); + Assertions.assertEquals("PROCESSING", result.getRoyaltyStatus()); + Assertions.assertEquals("NOT_STARTED", result.getFzRefundStatus()); + Assertions.assertNotNull(result.getSettleConfirmReq()); + Assertions.assertNotNull(result.getRoyaltyRes()); + Assertions.assertNull(result.getMchPaySettingJson()); + } + + /** + * 支付宝仅完成确认结算时,历史 createReq 不得被误投影为尚未发起的平台抽佣请求。 + */ + @Test + void directPayConfirmRequestShouldNotBeDisplayedAsRoyaltyRequest() { + ThirdPayOrderVo order = alipayOrder(); + ThirdFzOrderEntity fzOrder = new ThirdFzOrderEntity(); + fzOrder.setPayNo(order.getPayNo()); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_UNPAID.getType()); + fzOrder.setCreateReq(JSONUtil.createObj().set("outRequestNo", "settle_pay-1001").toString()); + fzOrder.setCreateRes(JSONUtil.createObj() + .set("provider", "ALIPAY") + .set("settleConfirm", JSONUtil.createObj() + .set("status", "SUCCESS") + .set("request", JSONUtil.createObj().set("outRequestNo", "settle_pay-1001"))) + .toString()); + + ReflectionTestUtils.invokeMethod(service, "fillProfitSharingStage", order, fzOrder); + + Assertions.assertEquals("SUCCESS", order.getSettleConfirmStatus()); + Assertions.assertEquals("NOT_STARTED", order.getRoyaltyStatus()); + Assertions.assertNull(order.getRoyaltyReq()); + } + + /** + * 验证同一支付单的告警任务优先于较新的成功任务展示,人工重试不会误选已完成任务。 + */ + @Test + void taskProjectionShouldPreferActionableAlertOverNewerSuccess() { + ThirdPayOrderVo order = alipayOrder(); + ThirdPaymentTaskEntity success = new ThirdPaymentTaskEntity(); + success.setId(101L); + success.setPayNo(order.getPayNo()); + success.setStatus(ThirdPaymentTaskStatusEnum.SUCCESS.getCode()); + ThirdPaymentTaskEntity alert = new ThirdPaymentTaskEntity(); + alert.setId(102L); + alert.setPayNo(order.getPayNo()); + alert.setStatus(ThirdPaymentTaskStatusEnum.ALERT.getCode()); + alert.setAttemptCount(15); + alert.setLastErrorMessage("渠道状态长期未收敛"); + when(thirdPaymentTaskMapper.selectList(any(Wrapper.class))).thenReturn(List.of(success, alert)); + + ReflectionTestUtils.invokeMethod(service, "fillTaskStates", List.of(order)); + + Assertions.assertEquals(alert.getId(), order.getTaskId()); + Assertions.assertEquals(ThirdPaymentTaskStatusEnum.ALERT.getCode(), order.getTaskStatus()); + Assertions.assertEquals(15, order.getTaskAttemptCount()); + } + + /** + * 构造带完整支付宝商户快照的支付订单,用于验证出参只保留 smid。 + * + * @return 支付宝直付通支付订单视图 + */ + private ThirdPayOrderVo alipayOrder() { + MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting(); + aliPaySetting.setSubMchid("smid-1001"); + aliPaySetting.setAppAuthToken("sensitive-auth-token"); + MchPaySettingEntity setting = new MchPaySettingEntity(); + setting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); + + ThirdPayOrderVo order = new ThirdPayOrderVo(); + order.setId(1L); + order.setPayNo("pay-1001"); + order.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType()); + order.setUserId(10L); + order.setMchId(20L); + order.setEnableFz(YesNoEnum.YES.getCode()); + order.setMchPaySettingJson(JSONUtil.toJsonStr(setting)); + return order; + } + + /** + * 构造确认结算成功但平台抽佣仍在处理中的分账快照。 + * + * @param payNo 对应支付订单号 + * @return 分账订单 + */ + private ThirdFzOrderEntity fzOrder(String payNo) { + ThirdFzOrderEntity fzOrder = new ThirdFzOrderEntity(); + fzOrder.setPayNo(payNo); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_UNPAID.getType()); + fzOrder.setCreateRes(JSONUtil.createObj() + .set("provider", "ALIPAY") + .set("settleConfirm", JSONUtil.createObj() + .set("status", "SUCCESS") + .set("request", JSONUtil.createObj().set("outRequestNo", "settle_pay-1001"))) + .set("royalty", JSONUtil.createObj() + .set("status", "PROCESSING") + .set("response", JSONUtil.createObj().set("subCode", "SYSTEM_ERROR"))) + .toString()); + return fzOrder; + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPaymentTaskServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPaymentTaskServiceImplTest.java new file mode 100644 index 00000000..a3c45539 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPaymentTaskServiceImplTest.java @@ -0,0 +1,274 @@ +package com.bsd.pjcloud.thirdparty.service.impl.pay; + +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPaymentTaskDispatchDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPaymentTaskEntity; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskAttemptMapper; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskMapper; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService; +import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService; +import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService; +import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusService; +import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatFzService; +import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService; +import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.lang.reflect.Method; +import java.time.LocalDateTime; +import java.util.Collections; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * 支付任务 Worker 聚合会话分支测试,重点验证历史任务不会并发重复访问支付渠道。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdPaymentTaskServiceImplTest { + + /** 支付任务尝试日志 Mapper Mock,本组测试不执行异步租约入口。 */ + @Mock + private ThirdPaymentTaskAttemptMapper attemptMapper; + + /** 支付任务 Mapper Mock,用于验证分发租约使用 thirdparty 当前时间。 */ + @Mock + private ThirdPaymentTaskMapper taskMapper; + + /** 支付订单服务 Mock,用于确认关闭分支不直接读写渠道子订单。 */ + @Mock + private ThirdPayOrderService payOrderService; + + /** 分账订单服务 Mock,本组聚合测试不使用。 */ + @Mock + private ThirdFzOrderService fzOrderService; + + /** App 支付编排服务 Mock,本组聚合测试不使用。 */ + @Mock + private ThirdAppPlusService appPlusService; + + /** 微信分账服务 Mock,本组聚合测试不使用。 */ + @Mock + private ThirdAppPlusWechatFzService wechatFzService; + + /** 支付宝直付通服务 Mock,本组聚合测试不使用。 */ + @Mock + private ThirdAppPlusAliPayService aliPayService; + + /** 聚合会话服务 Mock,用于返回最新会话状态和验证本地终态更新。 */ + @Mock + private ThirdAggregatePaySessionService aggregateSessionService; + + /** 聚合支付编排服务 Mock,用于验证唯一会话任务负责渠道查单。 */ + @Mock + private ThirdAggregateOrderPayService aggregateOrderPayService; + + /** 稳定任务写入器 Mock,用于验证历史关闭任务只物化唯一会话任务。 */ + @Mock + private ThirdPaymentTaskEnqueuer paymentTaskEnqueuer; + + /** 被测支付任务 Worker。 */ + @InjectMocks + private ThirdPaymentTaskServiceImpl service; + + /** + * 将继承自 MyBatis-Plus ServiceImpl 的 Mapper 字段替换为 Mock,避免分发测试访问数据库。 + */ + @BeforeEach + void setUpBaseMapper() { + ReflectionTestUtils.setField(service, "baseMapper", taskMapper); + } + + /** + * 验证已绑定渠道单的历史会话只补建唯一会话任务,不在关闭任务中再次访问渠道。 + * + * @throws Exception 反射调用私有任务分派方法失败时抛出 + */ + @Test + void aggregateCloseWithChannelOrderShouldDelegateToStableSessionTask() throws Exception { + ThirdAggregatePaySessionEntity session = historicalSession("pay-1001"); + when(aggregateSessionService.getById(session.getId())).thenReturn(session); + + Object result = runTask(task(ThirdPaymentTaskTypeEnum.AGGREGATE_CLOSE, session)); + + Assertions.assertTrue(resultSuccess(result)); + Assertions.assertEquals("DELEGATED_TO_AGGREGATE_SESSION", resultExternalStatus(result)); + verify(paymentTaskEnqueuer).enqueueAggregateSession(eq(session.getTenantId()), eq(session.getId()), + eq(session.getAggregateNo()), eq(session.getPayNo()), any(LocalDateTime.class)); + verifyNoInteractions(aggregateOrderPayService, payOrderService); + verify(aggregateSessionService, never()).markTerminal(any(), any()); + } + + /** + * 验证从未绑定渠道单的历史会话可以本地关闭,且不会创建无意义的渠道查询任务。 + * + * @throws Exception 反射调用私有任务分派方法失败时抛出 + */ + @Test + void aggregateCloseWithoutChannelOrderShouldCloseLocally() throws Exception { + ThirdAggregatePaySessionEntity session = historicalSession(null); + when(aggregateSessionService.getById(session.getId())).thenReturn(session); + + Object result = runTask(task(ThirdPaymentTaskTypeEnum.AGGREGATE_CLOSE, session)); + + Assertions.assertTrue(resultSuccess(result)); + Assertions.assertEquals("NO_CHANNEL_ORDER", resultExternalStatus(result)); + verify(aggregateSessionService).markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED); + verifyNoInteractions(aggregateOrderPayService, payOrderService, paymentTaskEnqueuer); + } + + /** + * 验证聚合渠道状态尚未收敛时保持同一会话任务重试,不能把 EXPIRED 误当最终成功。 + * + * @throws Exception 反射调用私有任务分派方法失败时抛出 + */ + @Test + void aggregateSessionWithUnknownChannelStateShouldRetry() throws Exception { + ThirdAggregatePaySessionEntity session = historicalSession("pay-1001"); + when(aggregateSessionService.getById(session.getId())).thenReturn(session); + + Object result = runTask(task(ThirdPaymentTaskTypeEnum.AGGREGATE_SESSION, session)); + + Assertions.assertFalse(resultSuccess(result)); + Assertions.assertEquals("PROCESSING", resultExternalStatus(result)); + verify(aggregateOrderPayService).processPendingSession(session.getId()); + } + + /** + * 验证同步创建响应晚于任务领取时,PAY_CREATE 立即完成并只补建稳定 PAY_QUERY,避免两类任务重复查单。 + * + * @throws Exception 反射调用私有任务分派方法失败时抛出 + */ + @Test + void acceptedPayCreateShouldDelegateToStablePayQuery() throws Exception { + ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity(); + payOrder.setId(301L); + payOrder.setTenantId(9L); + payOrder.setPayNo("pay-create-1001"); + payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType()); + payOrder.setPayRes("{\"accepted\":true}"); + when(payOrderService.getById(payOrder.getId())).thenReturn(payOrder); + ThirdPaymentTaskEntity task = new ThirdPaymentTaskEntity(); + task.setTaskType(ThirdPaymentTaskTypeEnum.PAY_CREATE.getCode()); + task.setPayOrderId(payOrder.getId()); + + Object result = runTask(task); + + Assertions.assertTrue(resultSuccess(result)); + Assertions.assertEquals("CREATE_ACCEPTED", resultExternalStatus(result)); + verify(paymentTaskEnqueuer).enqueuePayQuery(payOrder.getTenantId(), payOrder.getId(), payOrder.getPayNo()); + verifyNoInteractions(appPlusService); + } + + /** + * 验证 Quartz misfire 产生的陈旧触发时间不会让 thirdparty 创建已经过期的执行租约。 + */ + @Test + void dispatchShouldUseReceiverClockInsteadOfStaleQuartzTriggerTime() { + ThirdPaymentTaskDispatchDto dto = new ThirdPaymentTaskDispatchDto(); + dto.setTaskType(ThirdPaymentTaskTypeEnum.PAY_QUERY.getCode()); + dto.setBatchSize(100); + dto.setTriggerTime(LocalDateTime.now().minusHours(1)); + when(taskMapper.selectMaterializationCandidates(eq(ThirdPaymentTaskTypeEnum.PAY_QUERY.getCode()), + any(LocalDateTime.class), eq(100))).thenReturn(Collections.emptyList()); + when(taskMapper.selectDueTasks(eq(ThirdPaymentTaskTypeEnum.PAY_QUERY.getCode()), + any(LocalDateTime.class), eq(100))).thenReturn(Collections.emptyList()); + LocalDateTime beforeDispatch = LocalDateTime.now(); + + service.dispatch(dto); + + ArgumentCaptor nowCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(taskMapper).recoverExpiredLeases(eq(ThirdPaymentTaskTypeEnum.PAY_QUERY.getCode()), nowCaptor.capture()); + Assertions.assertFalse(nowCaptor.getValue().isBefore(beforeDispatch)); + Assertions.assertTrue(nowCaptor.getValue().isAfter(dto.getTriggerTime())); + } + + /** + * 构造历史聚合会话,状态保持 EXPIRED 以模拟服务重启后的恢复记录。 + * + * @param payNo 渠道支付单号;为空表示从未创建渠道子订单 + * @return 带稳定租户和聚合会话号的历史记录 + */ + private ThirdAggregatePaySessionEntity historicalSession(String payNo) { + ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity(); + session.setId(201L); + session.setTenantId(9L); + session.setAggregateNo("aggregate-1001"); + session.setPayNo(payNo); + session.setStatus(ThirdAggregatePaySessionStatusEnum.EXPIRED.getType()); + return session; + } + + /** + * 构造关联指定聚合会话的任务事实。 + * + * @param type 待验证的任务类型 + * @param session 任务关联聚合会话 + * @return 可直接传入 Worker 分派方法的任务 + */ + private ThirdPaymentTaskEntity task(ThirdPaymentTaskTypeEnum type, ThirdAggregatePaySessionEntity session) { + ThirdPaymentTaskEntity task = new ThirdPaymentTaskEntity(); + task.setTaskType(type.getCode()); + task.setTenantId(session.getTenantId()); + task.setAggregateSessionId(session.getId()); + task.setAggregateNo(session.getAggregateNo()); + task.setPayNo(session.getPayNo()); + return task; + } + + /** + * 反射调用 Worker 私有分派方法,使测试只覆盖状态映射而不启动线程池和数据库租约。 + * + * @param task 待执行任务 + * @return Worker 内部任务结果记录 + * @throws Exception 方法查找、访问或执行失败时抛出 + */ + private Object runTask(ThirdPaymentTaskEntity task) throws Exception { + Method method = ThirdPaymentTaskServiceImpl.class.getDeclaredMethod("runTask", ThirdPaymentTaskEntity.class); + method.setAccessible(true); + return method.invoke(service, task); + } + + /** + * 读取内部任务结果的成功标识。 + * + * @param result Worker 内部任务结果记录 + * @return 当前任务是否完成 + * @throws Exception 结果访问器不可用时抛出 + */ + private boolean resultSuccess(Object result) throws Exception { + Method method = result.getClass().getDeclaredMethod("success"); + method.setAccessible(true); + return (boolean) method.invoke(result); + } + + /** + * 读取内部任务结果保存的外部状态。 + * + * @param result Worker 内部任务结果记录 + * @return 最近渠道或本地状态 + * @throws Exception 结果访问器不可用时抛出 + */ + private String resultExternalStatus(Object result) throws Exception { + Method method = result.getClass().getDeclaredMethod("externalStatus"); + method.setAccessible(true); + return (String) method.invoke(result); + } +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java index a61771bd..ca66205a 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java @@ -23,6 +23,7 @@ import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; @@ -37,7 +38,7 @@ import java.util.List; @Data @Schema(description = "系统用户传输对象") @EqualsAndHashCode(callSuper = true) -public class UserDTO extends SysUser implements BaseIsIncludeTreeInterface { +public class UserDTO extends SysUser implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface { /** * 角色ID @@ -63,15 +64,15 @@ public class UserDTO extends SysUser implements BaseIsIncludeTreeInterface { private List identityTypes; /** - * 学生身份扩展信息 + * 学生身份扩展信息;写入时作为学生资料提交,查询时按各字段约定匹配。 */ - @Schema(description = "学生身份扩展信息") + @Schema(description = "学生身份扩展信息或学生身份查询条件") private SysUserStudentDto studentInfo; /** - * 老师身份扩展信息,与学生身份扩展信息独立传输。 + * 老师身份扩展信息,与学生身份扩展信息独立传输;查询时老师学号按完整编号精确匹配。 */ - @Schema(description = "老师身份扩展信息") + @Schema(description = "老师身份扩展信息或老师身份查询条件") private SysUserTeacherDto teacherInfo; /** diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserFaceService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserFaceService.java index bf97a3c2..f5eb95df 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserFaceService.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserFaceService.java @@ -1,12 +1,15 @@ package com.bsd.pjcloud.admin.api.feign; import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; import com.bsd.pjcloud.common.core.constant.ServiceNameConstants; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.feign.annotation.NoToken; import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import java.util.List; @@ -25,4 +28,15 @@ public interface RemoteUserFaceService { @NoToken @PostMapping("/user-face/inner/status-count") R> listStatusCounts(@RequestBody List userIds); + + /** + * 按用户和虹软样本编号读取审核通过的人脸原图地址,供订单证据服务复制订单专属快照。 + * + * @param userId 识别用户主键 + * @param sampleId 虹软样本编号 + * @return 受信人脸图片来源;不存在或未审核通过时为空 + */ + @NoToken + @GetMapping("/user-face/inner/evidence-source") + R getEvidenceSource(@RequestParam Long userId, @RequestParam String sampleId); } diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java index 2d9f122d..d71761cf 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java @@ -59,7 +59,7 @@ public interface RemoteUserService { /** * 按当前登录人的数据权限分页查询用户基础信息和身份资料。 * - *

该接口不查询角色、岗位、人脸和审批流程,适用于只需要用户、部门及身份展示信息的业务列表。

+ *

该接口不返回角色、岗位、人脸和审批资料,但支持按人脸录入状态筛选,适用于只需要用户、部门及身份展示信息的业务列表。

* * @param current 当前页码,从 1 开始 * @param size 每页用户数量 diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceEvidenceSourceVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceEvidenceSourceVo.java new file mode 100644 index 00000000..c70843df --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceEvidenceSourceVo.java @@ -0,0 +1,38 @@ +package com.bsd.pjcloud.admin.api.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 供 Hardware 订单证据服务使用的受信人脸原图来源,不包含特征数据和审核流程内部字段。 + */ +@Data +@Schema(description = "人脸证据原图来源") +public class SysUserFaceEvidenceSourceVo { + + /** + * 人脸所属用户主键。 + */ + @Schema(description = "用户主键") + private Long userId; + + /** + * 虹软样本编号。 + */ + @Schema(description = "样本编号") + private String sampleId; + + /** + * 审核通过的人脸图片地址。 + */ + @Schema(description = "人脸图片地址") + private String faceImg; + + /** + * 人脸来源最后更新时间,用于证据审计和图片版本判断。 + */ + @Schema(description = "来源更新时间") + private LocalDateTime sourceUpdatedAt; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserIdentityQueryInterface.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserIdentityQueryInterface.java new file mode 100644 index 00000000..e2d05c1e --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserIdentityQueryInterface.java @@ -0,0 +1,71 @@ +package com.bsd.pjcloud.admin.api.vo.base; + +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; + +import java.util.List; + +/** + * 用户身份查询条件公共契约。 + * + *

管理端用户查询和跨服务业务查询都通过这组字段筛选用户身份及人脸录入状态。身份类型使用 UPMS + * 的持久化/接口编码;学生、老师扩展资料中的字段沿用各自 DTO 的查询语义。

+ */ +public interface BaseUserIdentityQueryInterface { + + /** + * 获取用户身份类型筛选集合。 + * + * @return UPMS 身份协议编码集合;为空时不限制身份类型 + */ + List getIdentityTypes(); + + /** + * 设置用户身份类型筛选集合。 + * + * @param identityTypes UPMS 身份协议编码集合;为空时不限制身份类型 + */ + void setIdentityTypes(List identityTypes); + + /** + * 获取学生身份扩展筛选条件。 + * + * @return 学号、学校、班级或监护人手机号条件;对象为空或字段均为空时不限制学生资料 + */ + SysUserStudentDto getStudentInfo(); + + /** + * 设置学生身份扩展筛选条件。 + * + * @param studentInfo 学生身份查询条件;为空或字段均为空时不限制学生资料 + */ + void setStudentInfo(SysUserStudentDto studentInfo); + + /** + * 获取老师身份扩展筛选条件。 + * + * @return 老师学号条件;对象为空或学号为空时不限制老师资料 + */ + SysUserTeacherDto getTeacherInfo(); + + /** + * 设置老师身份扩展筛选条件。 + * + * @param teacherInfo 老师身份查询条件;为空或学号为空时不限制老师资料 + */ + void setTeacherInfo(SysUserTeacherDto teacherInfo); + + /** + * 获取人脸录入状态筛选条件。 + * + * @return {@code 1} 表示存在未删除人脸记录,{@code 0} 表示不存在,空值表示不限制 + */ + String getHasFace(); + + /** + * 设置人脸录入状态筛选条件。 + * + * @param hasFace {@code 1} 表示已录入,{@code 0} 表示未录入,空值表示全部 + */ + void setHasFace(String hasFace); +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java index b70bff65..5c8dc258 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java @@ -334,7 +334,7 @@ public class SysUserController { /** * 分页查询当前操作者数据权限内的用户基础信息和身份资料。 * - *

该接口供无需角色、岗位、人脸和审批信息的业务列表调用,避免复用管理端完整用户分页产生额外查询。

+ *

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

* * @param page 分页参数 * @param userDTO 用户关键字、部门树和指定用户筛选条件 diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java index b6e22ff5..b2d0d574 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java @@ -16,6 +16,7 @@ import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardOptionVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardWithdrawVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncSnapshotVo; import com.bsd.pjcloud.admin.service.SysUserFaceService; import com.bsd.pjcloud.common.core.util.R; @@ -77,6 +78,16 @@ public class SysUserFaceController { public R> listStatusCounts(@RequestBody List userIds) { return R.ok(sysUserFaceService.listStatusCounts(userIds)); } + + /** + * Hardware 内部复制订单参考人脸时读取审核通过的人脸来源。 + */ + @Inner + @Operation(summary = "查询订单证据参考人脸来源") + @GetMapping("/inner/evidence-source") + public R evidenceSource(@RequestParam Long userId, @RequestParam String sampleId) { + return R.ok(sysUserFaceService.evidenceSource(userId, sampleId)); + } /* ************************************* end 远程调用 end ************************************* */ /* ************************************* start app start ************************************* */ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysPostMapper.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysPostMapper.java index f875cb1c..c020ea5d 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysPostMapper.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysPostMapper.java @@ -18,8 +18,10 @@ package com.bsd.pjcloud.admin.mapper; import com.bsd.pjcloud.admin.api.entity.SysPost; +import com.bsd.pjcloud.admin.mapper.vo.UserPostPageRow; import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; import java.util.List; @@ -39,4 +41,12 @@ public interface SysPostMapper extends PjcloudBaseMapper { */ List listPostsByUserId(Long userId); + /** + * 批量查询当前分页用户的岗位,返回用户 ID 与岗位资料的扁平行,避免逐用户嵌套查询。 + * + * @param userIds 当前页用户主键列表;为空时不应调用 + * @return 按用户 ID、岗位排序值和岗位 ID 升序返回岗位资料,未分配岗位的用户不产生记录 + */ + List listPostsByUserIds(@Param("userIds") List userIds); + } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysRoleMapper.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysRoleMapper.java index fd852b90..7632019e 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysRoleMapper.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysRoleMapper.java @@ -20,8 +20,10 @@ package com.bsd.pjcloud.admin.mapper; import com.bsd.pjcloud.admin.api.entity.SysRole; +import com.bsd.pjcloud.admin.mapper.vo.UserRolePageRow; import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; import java.util.List; @@ -43,4 +45,12 @@ public interface SysRoleMapper extends PjcloudBaseMapper { */ List listRolesByUserId(Long userId); + /** + * 批量查询当前分页用户的角色,返回用户 ID 与角色资料的扁平行,避免逐用户嵌套查询。 + * + * @param userIds 当前页用户主键列表;为空时不应调用 + * @return 按用户 ID、角色 ID 升序返回角色资料,未分配角色的用户不产生记录 + */ + List listRolesByUserIds(@Param("userIds") List userIds); + } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java index def07295..d088a570 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java @@ -9,6 +9,7 @@ import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity; import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncRecordVo; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.data.datascope.DataScope; @@ -23,6 +24,28 @@ import java.util.List; @Mapper public interface SysUserFaceMapper extends PjcloudBaseMapper { + /** + * 查询指定用户审核通过的人脸来源,供订单证据服务复制不可变参考图。 + * + * @param userId 用户主键 + * @param sampleId 虹软样本编号 + * @param processStatus 审核通过状态编码 + * @return 受信图片来源;不存在时为空 + */ + default SysUserFaceEvidenceSourceVo evidenceSource(Long userId, String sampleId, String processStatus) { + SysUserFaceEntity entity = selectOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(SysUserFaceEntity::getUserId, userId) + .eq(SysUserFaceEntity::getAutoCode, sampleId) + .eq(SysUserFaceEntity::getProcessStatus, processStatus)); + if (entity == null) return null; + SysUserFaceEvidenceSourceVo source = new SysUserFaceEvidenceSourceVo(); + source.setUserId(entity.getUserId()); + source.setSampleId(entity.getAutoCode()); + source.setFaceImg(entity.getFaceImg()); + source.setSourceUpdatedAt(entity.getUpdateTime()); + return source; + } + Page scopePageList(Page page, @Param("pa") SysUserFaceDto dto, DataScope dataScope); List groupConcatFaces(@Param("pa") SysUserFaceDto dto); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/vo/UserPostPageRow.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/vo/UserPostPageRow.java new file mode 100644 index 00000000..2bf0a0e1 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/vo/UserPostPageRow.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2018-2025, pjcloud All rights reserved. + */ + +package com.bsd.pjcloud.admin.mapper.vo; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 用户分页岗位批量查询的扁平结果行。 + * + *

该类型仅服务于 Mapper 到 Service 的批量回填,不作为对外接口模型,避免为分页结果增加嵌套查询。

+ */ +@Data +public class UserPostPageRow { + + /** 当前用户主键,来源于 sys_user_post.user_id。 */ + private Long userId; + + /** 岗位主键,来源于 sys_post.post_id。 */ + private Long postId; + + /** 岗位编码,来源于 sys_post.post_code。 */ + private String postCode; + + /** 岗位名称,来源于 sys_post.post_name。 */ + private String postName; + + /** 岗位排序值,来源于 sys_post.post_sort。 */ + private Integer postSort; + + /** 岗位删除标记,来源于 sys_post.del_flag。 */ + private String delFlag; + + /** 岗位创建时间,来源于 sys_post.create_time。 */ + private LocalDateTime createTime; + + /** 岗位更新时间,来源于 sys_post.update_time。 */ + private LocalDateTime updateTime; + + /** 岗位备注,来源于 sys_post.remark。 */ + private String remark; +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/vo/UserRolePageRow.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/vo/UserRolePageRow.java new file mode 100644 index 00000000..299d9367 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/vo/UserRolePageRow.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2018-2025, pjcloud All rights reserved. + */ + +package com.bsd.pjcloud.admin.mapper.vo; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 用户分页角色批量查询的扁平结果行。 + * + *

该类型仅服务于 Mapper 到 Service 的批量回填,不作为对外接口模型,避免为分页结果增加嵌套查询。

+ */ +@Data +public class UserRolePageRow { + + /** 当前用户主键,来源于 sys_user_role.user_id。 */ + private Long userId; + + /** 角色主键,来源于 sys_role.role_id。 */ + private Long roleId; + + /** 角色名称,来源于 sys_role.role_name。 */ + private String roleName; + + /** 角色编码,来源于 sys_role.role_code。 */ + private String roleCode; + + /** 角色描述,来源于 sys_role.role_desc。 */ + private String roleDesc; + + /** 数据权限类型,来源于 sys_role.ds_type。 */ + private Integer dsType; + + /** 自定义数据权限范围,来源于 sys_role.ds_scope。 */ + private String dsScope; + + /** 角色创建时间,来源于 sys_role.create_time。 */ + private LocalDateTime createTime; + + /** 角色更新时间,来源于 sys_role.update_time。 */ + private LocalDateTime updateTime; + + /** 角色删除标记,来源于 sys_role.del_flag。 */ + private String delFlag; +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java index e0868db9..85acfc2a 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java @@ -11,6 +11,7 @@ import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardOptionVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardWithdrawVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncSnapshotVo; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.flow.task.dto.ProcessNodeRecordAssignUserParamDto; @@ -57,6 +58,15 @@ public interface SysUserFaceService extends IService { */ List listStatusCounts(List userIds); + /** + * 读取审核通过人脸的受信原图来源,供订单证据服务复制订单快照。 + * + * @param userId 用户主键 + * @param sampleId 虹软样本编号 + * @return 原图来源;不存在时为空 + */ + SysUserFaceEvidenceSourceVo evidenceSource(Long userId, String sampleId); + /** * 分页查询当前用户的有效被监护人,或显式指定的本人/有效被监护人人脸审核记录。 */ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java index a0709552..62d2cf93 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java @@ -19,6 +19,7 @@ import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardOptionVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardWithdrawVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncRecordVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncSnapshotVo; import com.bsd.pjcloud.admin.api.vo.user.SysUserGuardianWardOptionVo; @@ -263,6 +264,15 @@ public class SysUserFaceServiceImpl extends ServiceImpl impl private final SysUserPostMapper sysUserPostMapper; + /** 角色批量查询 Mapper,用于按当前分页用户一次性回填角色。 */ + private final SysRoleMapper sysRoleMapper; + + /** 岗位批量查询 Mapper,用于按当前分页用户一次性回填岗位。 */ + private final SysPostMapper sysPostMapper; + private final CacheManager cacheManager; private final StringRedisTemplate redisTemplate; @@ -338,29 +348,18 @@ public class SysUserServiceImpl extends ServiceImpl impl IPage list = baseMapper.getUserVosPage(page, userDTO, dataScope); if (ObjectUtil.isNotEmpty(list.getRecords())) { List userIds = list.getRecords().stream().map(UserVO::getUserId).distinct().toList(); + fillUserRolesAndPosts(list.getRecords(), userIds); // 分页结果统一补齐身份及扩展资料,保证管理端列表与详情接口使用同一套身份语义。 sysUserIdentityService.fillUserIdentities(list.getRecords()); if (ObjectUtil.isNotEmpty(userIds)) { SysUserFaceDto faceDto = new SysUserFaceDto(); + // 人脸聚合必须限定为当前页,禁止每次分页扫描并传输当前租户的全部人脸。 + faceDto.setUserIds(userIds); List faceList = sysUserFaceMapper.groupConcatFaces(faceDto); DataUtil.convertFacesList(list.getRecords(), CollectionUtils.convertMap(faceList, SysUserFaceVo::getUserId)); } - // 审批流程 - if (userDTO.getIsFindCheckUser()) { - List processInstanceIds = list.getRecords().stream().map(UserVO::getProcessId).filter(ObjectUtil::isNotEmpty).distinct().toList(); - if (ObjectUtil.isNotEmpty(processInstanceIds)) { - List taskList = flowUtil.queryProcessInstanceList(SecurityUtils.getUser().getId().toString(), processInstanceIds); - if (ObjectUtil.isNotEmpty(taskList)) { - Map taskMap = CollectionUtils.convertMap(taskList, TaskDto::getProcessInstanceId); - CollectionUtils.convertList(list.getRecords(), v -> { - TaskDto task = taskMap.get(v.getProcessId()); - if (ObjectUtil.isNotEmpty(task)) { - v.setIsCheckUser(true); - } - return v; - }); - } - } + if (Boolean.TRUE.equals(userDTO.getIsFindCheckUser())) { + fillPendingApprovalFlags(list.getRecords()); } } return list; @@ -370,7 +369,7 @@ public class SysUserServiceImpl extends ServiceImpl impl * 分页查询用户基础信息并批量补齐身份资料。 * *

预约等业务列表只需要用户、部门和身份信息,因此这里使用无嵌套角色、岗位映射的专用 SQL, - * 同时跳过人脸和审批远程查询。

+ * 同时不返回人脸资料和审批信息,但保留人脸录入状态筛选。

* * @param page 分页参数 * @param userDTO 用户关键字、部门树和指定用户筛选条件 @@ -408,6 +407,8 @@ public class SysUserServiceImpl extends ServiceImpl impl } IPage list = baseMapper.getUserVosPage(page, userDTO, dataScope); if (ObjectUtil.isNotEmpty(list.getRecords())) { + List userIds = list.getRecords().stream().map(UserVO::getUserId).distinct().toList(); + fillUserRolesAndPosts(list.getRecords(), userIds); sysUserIdentityService.fillUserIdentities(list.getRecords()); } return list; @@ -418,32 +419,80 @@ public class SysUserServiceImpl extends ServiceImpl impl IPage list = baseMapper.getUserVosPage(page, userDTO, null); if (ObjectUtil.isNotEmpty(list.getRecords())) { List userIds = list.getRecords().stream().map(UserVO::getUserId).distinct().toList(); + fillUserRolesAndPosts(list.getRecords(), userIds); if (ObjectUtil.isNotEmpty(userIds)) { SysUserFaceDto faceDto = new SysUserFaceDto(); + faceDto.setUserIds(userIds); List faceList = sysUserFaceMapper.groupConcatFaces(faceDto); DataUtil.convertFacesList(list.getRecords(), CollectionUtils.convertMap(faceList, SysUserFaceVo::getUserId)); } - // 审批流程 - if (userDTO.getIsFindCheckUser()) { - List processInstanceIds = list.getRecords().stream().map(UserVO::getProcessId).filter(ObjectUtil::isNotEmpty).distinct().toList(); - if (ObjectUtil.isNotEmpty(processInstanceIds)) { - List taskList = flowUtil.queryProcessInstanceList(SecurityUtils.getUser().getId().toString(), processInstanceIds); - if (ObjectUtil.isNotEmpty(taskList)) { - Map taskMap = CollectionUtils.convertMap(taskList, TaskDto::getProcessInstanceId); - CollectionUtils.convertList(list.getRecords(), v -> { - TaskDto task = taskMap.get(v.getProcessId()); - if (ObjectUtil.isNotEmpty(task)) { - v.setIsCheckUser(true); - } - return v; - }); - } - } + if (Boolean.TRUE.equals(userDTO.getIsFindCheckUser())) { + fillPendingApprovalFlags(list.getRecords()); } } return list; } + /** + * 按当前页用户批量回填角色和岗位,将原先随分页条数线性增长的嵌套查询固定为两次查询。 + * + * @param userVos 当前页用户展示对象,方法会原位设置角色和岗位集合 + * @param userIds 当前页去重后的用户主键;为空时不访问数据库 + */ + private void fillUserRolesAndPosts(List userVos, List userIds) { + if (CollUtil.isEmpty(userVos) || CollUtil.isEmpty(userIds)) { + return; + } + + Map> rolesByUserId = new HashMap<>(); + for (UserRolePageRow row : sysRoleMapper.listRolesByUserIds(userIds)) { + SysRole role = new SysRole(); + BeanUtils.copyProperties(row, role); + rolesByUserId.computeIfAbsent(row.getUserId(), ignored -> new ArrayList<>()).add(role); + } + + Map> postsByUserId = new HashMap<>(); + for (UserPostPageRow row : sysPostMapper.listPostsByUserIds(userIds)) { + SysPost post = new SysPost(); + BeanUtils.copyProperties(row, post); + postsByUserId.computeIfAbsent(row.getUserId(), ignored -> new ArrayList<>()).add(post); + } + + for (UserVO userVo : userVos) { + userVo.setRoleList(new ArrayList<>(rolesByUserId.getOrDefault(userVo.getUserId(), Collections.emptyList()))); + userVo.setPostList(new ArrayList<>(postsByUserId.getOrDefault(userVo.getUserId(), Collections.emptyList()))); + } + } + + /** + * 查询当前操作人可处理的审核中用户流程,并回填列表的可审批标记。 + * + *

用户业务状态不是“审核中”时不可能存在待处理任务,因此不把已通过、已拒绝或已取消流程发送到远程流程服务。

+ * + * @param userVos 当前页用户展示对象;无认证操作人或无审核中流程时不调用远程服务 + */ + private void fillPendingApprovalFlags(List userVos) { + PjcloudUser operator = SecurityUtils.getUser(); + if (operator == null) { + return; + } + List processInstanceIds = userVos.stream() + .filter(userVo -> ObjectUtil.equals(userVo.getProcessStatus(), TaskCheckStatusEnum.PADDING.getCode())) + .map(UserVO::getProcessId) + .filter(ObjectUtil::isNotEmpty) + .distinct() + .toList(); + if (CollUtil.isEmpty(processInstanceIds)) { + return; + } + List taskList = flowUtil.queryProcessInstanceList(operator.getId().toString(), processInstanceIds); + if (CollUtil.isEmpty(taskList)) { + return; + } + Map taskMap = CollectionUtils.convertMap(taskList, TaskDto::getProcessInstanceId); + userVos.forEach(userVo -> userVo.setIsCheckUser(taskMap.containsKey(userVo.getProcessId()))); + } + /** * 通过ID查询用户信息 * diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysPostMapper.xml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysPostMapper.xml index 4dc219f5..b66b155c 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysPostMapper.xml +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysPostMapper.xml @@ -50,4 +50,25 @@ and up.user_id = #{userId} + + + diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysRoleMapper.xml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysRoleMapper.xml index ca8c1a82..3e37c1e7 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysRoleMapper.xml +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysRoleMapper.xml @@ -50,4 +50,26 @@ AND sys_role.del_flag = '0' and sys_user_role.user_id = #{userId} + + + diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml index 92e07f01..edd7d48b 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml @@ -136,6 +136,58 @@ d.dept_id + + + + AND EXISTS ( + SELECT 1 + FROM sys_user_identity user_identity + WHERE user_identity.user_id = u.user_id + AND user_identity.identity_type IN + + #{identityType} + + ) + + + + AND EXISTS ( + SELECT 1 + FROM sys_user_student student_identity + WHERE student_identity.user_id = u.user_id + + AND student_identity.student_no = #{query.studentInfo.studentNo} + + + AND student_identity.school_name = #{query.studentInfo.schoolName} + + + + AND student_identity.class_name LIKE #{studentClassNameLike} + + + + AND student_identity.guardian_phones LIKE #{studentGuardianPhoneLike} + + ) + + + + AND EXISTS ( + SELECT 1 + FROM sys_user_teacher teacher_identity + WHERE teacher_identity.user_id = u.user_id + AND teacher_identity.teacher_no = #{query.teacherInfo.teacherNo} + ) + + + - SELECT u.user_id, u.process_id, @@ -172,14 +228,14 @@ u.wx_pc_openid, u.apple_openid, u.qq_openid, - u.gitee_login, - u.osc_id, + u.gitee_login gitee_open_id, + u.osc_id osc_open_id, u.wx_cp_userid, u.wx_ding_userid, u.dept_id, u.create_by, - u.create_time ucreate_time, - u.update_time uupdate_time, + u.create_time create_time, + u.update_time update_time, u.del_flag, u.lock_flag, u.password_expire_flag, @@ -196,7 +252,6 @@ FROM sys_user u LEFT JOIN sys_dept d ON d.dept_id = u.dept_id - LEFT JOIN sys_user_role urole ON urole.user_id = u.user_id u.del_flag = '0' @@ -219,18 +274,7 @@ AND u.staffing_type = #{query.staffingType} - - - AND EXISTS ( - SELECT 1 - FROM sys_user_identity user_identity - WHERE user_identity.user_id = u.user_id - AND user_identity.identity_type IN - - #{identityType} - - ) - + AND u.lock_flag = #{query.lockFlag} @@ -252,10 +296,15 @@ - and urole.role_id IN - - #{v} - + AND EXISTS ( + SELECT 1 + FROM sys_user_role user_role + WHERE user_role.user_id = u.user_id + AND user_role.role_id IN + + #{v} + + ) @@ -288,7 +337,6 @@ - group by u.user_id ORDER BY u.create_time DESC, u.user_id DESC @@ -334,6 +382,24 @@ #{userId} + + + + + + AND EXISTS ( + SELECT 1 FROM sys_user_face f + WHERE f.user_id = u.user_id AND f.del_flag = '0' + ) + + + AND NOT EXISTS ( + SELECT 1 FROM sys_user_face f + WHERE f.user_id = u.user_id AND f.del_flag = '0' + ) + + + ORDER BY u.create_time DESC, u.user_id DESC @@ -396,18 +462,7 @@ AND u.dept_id = #{query.deptId} - - - AND EXISTS ( - SELECT 1 - FROM sys_user_identity user_identity - WHERE user_identity.user_id = u.user_id - AND user_identity.identity_type IN - - #{identityType} - - ) - + AND u.staffing_type = #{query.staffingType} diff --git a/pjcloud-visual/pjcloud-daemon-quartz/pom.xml b/pjcloud-visual/pjcloud-daemon-quartz/pom.xml index d7f33fbf..1bb34e64 100644 --- a/pjcloud-visual/pjcloud-daemon-quartz/pom.xml +++ b/pjcloud-visual/pjcloud-daemon-quartz/pom.xml @@ -15,6 +15,12 @@ 基于quartz后台跑批定时任务模块 + + + com.bsd + pjcloud-thirdparty-api + 5.7.0 + com.bsd pjcloud-daemon-quartz-api diff --git a/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializer.java b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializer.java new file mode 100644 index 00000000..6b78ab46 --- /dev/null +++ b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializer.java @@ -0,0 +1,97 @@ +package com.bsd.pjcloud.daemon.quartz.config; + +import com.bsd.pjcloud.daemon.quartz.job.ThirdPaymentDispatchJob; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.*; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +/** + * 代码托管的支付任务触发器,不受后台 {@code sys_job} 增删、暂停或频率修改影响。 + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class ThirdPaymentQuartzInitializer implements InitializingBean { + /** 支付固定任务所属 Quartz 分组,作为代码托管任务与后台可编辑任务的隔离边界。 */ + private static final String GROUP = "THIRD_PAYMENT_SYSTEM"; + /** 单个任务类型每轮最大领取数量,限制一次 Feign 分发对 thirdparty Worker 队列的压力。 */ + private static final int DEFAULT_BATCH_SIZE = 100; + /** 共享数据库持久化的 Quartz 调度器,由集群负责单次触发与故障接管。 */ + private final Scheduler scheduler; + + /** + * 支付任务与固定触发间隔;同一 JobKey 在 Quartz 集群中同一时刻只会由一个节点执行。 + * 间隔单位为秒,misfire 恢复时只推进后续触发,不突发补跑所有错过次数。 + */ + private static final List SCHEDULES = List.of( + new PaymentSchedule(ThirdPaymentTaskTypeEnum.PAY_CREATE, 5), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.PAY_QUERY, 5), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.REFUND_CREATE, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.REFUND_QUERY, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_CREATE, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_SETTLE_CONFIRM, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_ROYALTY, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_ROYALTY_QUERY, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_QUERY, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_REFUND_QUERY, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.BIZ_CALLBACK, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.AGGREGATE_SESSION, 5), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.PAY_CLOSE, 300), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.AGGREGATE_CLOSE, 300), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.RECONCILE, 300)); + + /** + * 幂等注册固定任务。多副本同时启动时,Quartz数据库唯一键裁决创建竞争。 + * @throws SchedulerException Quartz持久化失败 + */ + @Override + public void afterPropertiesSet() throws SchedulerException { + for (PaymentSchedule schedule : SCHEDULES) { + register(schedule); + } + } + + /** + * 创建或覆盖一个代码托管 Job,并重置其固定频率。 + * @param schedule 任务类型和秒级间隔 + * @throws SchedulerException Quartz持久化失败 + */ + private void register(PaymentSchedule schedule) throws SchedulerException { + String name = "third-payment-" + schedule.taskType().getCode().toLowerCase().replace('_', '-'); + JobKey jobKey = JobKey.jobKey(name, GROUP); + TriggerKey triggerKey = TriggerKey.triggerKey(name + "-trigger", GROUP); + JobDetail job = JobBuilder.newJob(ThirdPaymentDispatchJob.class).withIdentity(jobKey) + .usingJobData(ThirdPaymentDispatchJob.TASK_TYPE_KEY, schedule.taskType().getCode()) + .usingJobData(ThirdPaymentDispatchJob.BATCH_SIZE_KEY, DEFAULT_BATCH_SIZE) + .requestRecovery(true).storeDurably(true).build(); + SimpleScheduleBuilder simpleSchedule = SimpleScheduleBuilder.simpleSchedule() + .withIntervalInSeconds(schedule.intervalSeconds()).repeatForever() + .withMisfireHandlingInstructionNextWithRemainingCount(); + Trigger trigger = TriggerBuilder.newTrigger().withIdentity(triggerKey).forJob(jobKey) + .startNow().withSchedule(simpleSchedule).build(); + try { + scheduler.addJob(job, true); + if (scheduler.checkExists(triggerKey)) { + scheduler.rescheduleJob(triggerKey, trigger); + } else { + scheduler.scheduleJob(trigger); + } + } catch (ObjectAlreadyExistsException exception) { + // 多节点同时首次部署时另一个实例已完成注册,读取共享QRTZ表即可。 + log.info("支付Quartz任务已由其他集群节点初始化,jobKey:{}", jobKey); + } + } + + /** + * 固定任务频率配置。 + * @param taskType 支付任务类型 + * @param intervalSeconds 触发间隔,单位秒 + */ + private record PaymentSchedule(ThirdPaymentTaskTypeEnum taskType, int intervalSeconds) { + } +} diff --git a/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/job/ThirdPaymentDispatchJob.java b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/job/ThirdPaymentDispatchJob.java new file mode 100644 index 00000000..d6e15f88 --- /dev/null +++ b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/job/ThirdPaymentDispatchJob.java @@ -0,0 +1,58 @@ +package com.bsd.pjcloud.daemon.quartz.job; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.thirdparty.api.feign.RemoteThirdPaymentTaskService; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPaymentTaskDispatchDto; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPaymentTaskDispatchVo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.*; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDateTime; + +/** + * 支付任务固定 Quartz Job,只同步确认 thirdparty 接受分发,不直接访问支付 SDK。 + */ +@Slf4j +@DisallowConcurrentExecution +public class ThirdPaymentDispatchJob implements Job { + /** JobDataMap 中的任务类型键,值为任务类型持久化编码。 */ + public static final String TASK_TYPE_KEY = "taskType"; + /** JobDataMap 中的批量大小键,限制 thirdparty 单次可领取任务数量。 */ + public static final String BATCH_SIZE_KEY = "batchSize"; + /** thirdparty内部任务分发 Feign 客户端,由 Quartz JobFactory 注入。 */ + @Autowired + private RemoteThirdPaymentTaskService paymentTaskService; + + /** + * 调用服务发现后的 thirdparty 内部接口;业务响应失败会让 Quartz 记录本次执行失败。 + * @param context Quartz触发上下文 + * @throws JobExecutionException thirdparty未接受或调用异常 + */ + @Override + public void execute(JobExecutionContext context) throws JobExecutionException { + String taskType = context.getMergedJobDataMap().getString(TASK_TYPE_KEY); + int batchSize = context.getMergedJobDataMap().getIntValue(BATCH_SIZE_KEY); + ThirdPaymentTaskDispatchDto dto = new ThirdPaymentTaskDispatchDto(); + dto.setTaskType(taskType); + dto.setBatchSize(batchSize); + dto.setTriggerSource("QUARTZ"); + dto.setTriggerTime(LocalDateTime.now()); + try { + R result = paymentTaskService.dispatch(dto); + if (result == null || !result.isOk() || result.getData() == null) { + throw new JobExecutionException("thirdparty未接受支付任务分发:" + taskType); + } + ThirdPaymentTaskDispatchVo data = result.getData(); + if (data.isBusy() || data.getBacklogCount() > 0) { + log.warn("支付任务分发存在积压,taskType:{},claimed:{},backlog:{},busy:{}", + taskType, data.getClaimedCount(), data.getBacklogCount(), data.isBusy()); + } + } catch (JobExecutionException exception) { + throw exception; + } catch (Exception exception) { + throw new JobExecutionException("调用thirdparty支付任务分发失败:" + taskType, exception, false); + } + } +} diff --git a/pjcloud-visual/pjcloud-daemon-quartz/src/main/resources/quartz-config.yml b/pjcloud-visual/pjcloud-daemon-quartz/src/main/resources/quartz-config.yml index 446f6315..6a25742b 100644 --- a/pjcloud-visual/pjcloud-daemon-quartz/src/main/resources/quartz-config.yml +++ b/pjcloud-visual/pjcloud-daemon-quartz/src/main/resources/quartz-config.yml @@ -11,12 +11,14 @@ spring: class: org.springframework.scheduling.quartz.LocalDataSourceJobStore # 数据库存储 driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate # 数据库代理 tablePrefix: QRTZ_ # 表前缀 - isClustered: false # 集群 - clusterCheckinInterval: 30000 # 集群检查间隔 + isClustered: true # 支付任务只由Quartz集群触发,生产实例必须共享同一套QRTZ表 + clusterCheckinInterval: 10000 # 集群心跳,节点失联后由存活实例接管 + misfireThreshold: 60000 # 允许一分钟短暂抖动,恢复后按misfire策略继续触发 + acquireTriggersWithinLock: true # 多节点批量获取Trigger时在数据库锁内完成 useProperties: false threadPool: class: org.quartz.simpl.SimpleThreadPool # 线程池 - threadCount: 3 # 线程数量 + threadCount: 8 # 支付固定触发器与既有通用任务共享,外部支付调用不在Quartz线程执行 threadPriority: 5 # 线程优先级 threadsInheritContextClassLoaderOfInitializingThread: true # 是否继承类加载器 job-store-type: jdbc # 持久化到数据库 diff --git a/pjcloud-visual/pjcloud-daemon-quartz/src/test/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializerTest.java b/pjcloud-visual/pjcloud-daemon-quartz/src/test/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializerTest.java new file mode 100644 index 00000000..8eb8576f --- /dev/null +++ b/pjcloud-visual/pjcloud-daemon-quartz/src/test/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializerTest.java @@ -0,0 +1,59 @@ +package com.bsd.pjcloud.daemon.quartz.config; + +import com.bsd.pjcloud.daemon.quartz.job.ThirdPaymentDispatchJob; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum; +import org.junit.jupiter.api.Test; +import org.quartz.JobKey; +import org.quartz.Scheduler; +import org.quartz.SimpleTrigger; +import org.quartz.Trigger; +import org.quartz.TriggerKey; +import org.quartz.impl.StdSchedulerFactory; +import org.quartz.impl.matchers.GroupMatcher; + +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 支付固定 Quartz 触发器初始化测试,验证服务重复启动不会产生重复调度记录。 + */ +class ThirdPaymentQuartzInitializerTest { + + /** + * 使用内存 JobStore 连续初始化两次,确认 Job、Trigger、任务参数和固定频率均保持稳定。 + * + * @throws Exception Quartz 调度器创建、查询或关闭失败 + */ + @Test + void shouldRegisterManagedPaymentSchedulesIdempotently() throws Exception { + Properties properties = new Properties(); + properties.setProperty("org.quartz.scheduler.instanceName", "thirdPaymentInitializerTest"); + properties.setProperty("org.quartz.threadPool.threadCount", "1"); + properties.setProperty("org.quartz.jobStore.class", "org.quartz.simpl.RAMJobStore"); + Scheduler scheduler = new StdSchedulerFactory(properties).getScheduler(); + try { + ThirdPaymentQuartzInitializer initializer = new ThirdPaymentQuartzInitializer(scheduler); + initializer.afterPropertiesSet(); + initializer.afterPropertiesSet(); + + String group = "THIRD_PAYMENT_SYSTEM"; + assertThat(scheduler.getJobKeys(GroupMatcher.jobGroupEquals(group))).hasSize(15); + assertThat(scheduler.getTriggerKeys(GroupMatcher.triggerGroupEquals(group))).hasSize(15); + + JobKey jobKey = JobKey.jobKey("third-payment-pay-create", group); + assertThat(scheduler.getJobDetail(jobKey).getJobClass()).isEqualTo(ThirdPaymentDispatchJob.class); + assertThat(scheduler.getJobDetail(jobKey).getJobDataMap().getString(ThirdPaymentDispatchJob.TASK_TYPE_KEY)) + .isEqualTo(ThirdPaymentTaskTypeEnum.PAY_CREATE.getCode()); + assertThat(scheduler.getJobDetail(jobKey).getJobDataMap().getInt(ThirdPaymentDispatchJob.BATCH_SIZE_KEY)) + .isEqualTo(100); + + TriggerKey triggerKey = TriggerKey.triggerKey("third-payment-pay-create-trigger", group); + Trigger trigger = scheduler.getTrigger(triggerKey); + assertThat(trigger).isInstanceOf(SimpleTrigger.class); + assertThat(((SimpleTrigger) trigger).getRepeatInterval()).isEqualTo(5_000L); + } finally { + scheduler.shutdown(true); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionRemarkDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionRemarkDto.java new file mode 100644 index 00000000..74ef1f7a --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionRemarkDto.java @@ -0,0 +1,28 @@ +package com.bsd.pjcloud.wallet.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 钱包流水备注修改参数,只允许后台修改备注,不承载金额、余额和业务关联等不可变流水信息。 + */ +@Data +@Schema(description = "钱包流水备注修改参数") +public class WalletTransactionRemarkDto { + + /** + * 需要修改备注的钱包流水编号,由钱包流水列表返回。 + */ + @NotNull(message = "钱包流水编号不能为空") + @Schema(description = "钱包流水编号", requiredMode = Schema.RequiredMode.REQUIRED) + private Long id; + + /** + * 流水业务备注;允许传空以清除原备注,非空内容最多 256 个字符。 + */ + @Size(max = 256, message = "流水备注不能超过256个字符") + @Schema(description = "流水备注,最多256个字符") + private String remark; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigDto.java index cc1c5d82..fe838532 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigDto.java @@ -51,4 +51,16 @@ public class MchAutoRefundConfigDto { */ @Schema(description = "支付后可自动退款小时数") private Integer payTimeLimitHours; + + /** 过期预约订单自动退款开关,1开启、0关闭。 */ + @Schema(description = "过期预约自动退款开关") + private String expiredReserveRefundEnabled; + + /** 餐段结束后延迟退款小时数,单位小时,允许1至168。 */ + @Schema(description = "过期预约退款延迟小时数") + private Integer expiredReserveRefundDelayHours; + + /** 过期预约退款是否恢复库存,1恢复、0不恢复。 */ + @Schema(description = "过期预约退款是否退库存") + private String expiredReserveRefundRestoreInventory; } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeRefundDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeRefundDto.java index 3d064d93..53f8c464 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeRefundDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeRefundDto.java @@ -18,6 +18,12 @@ import org.hibernate.validator.constraints.Length; @Data public class WalletRechargeRefundDto { + /** 售后申请免审自动退款任务场景编码,持久化协议值为 10。 */ + public static final String AUTO_REFUND_TASK_TYPE_AFTER_SALE = "10"; + + /** 每日菜谱过期预约自动退款任务场景编码,持久化协议值为 20。 */ + public static final String AUTO_REFUND_TASK_TYPE_EXPIRED_RESERVE = "20"; + /** * 退款到账的钱包 ID,重复请求必须与首笔退款记录保持一致。 */ @@ -60,4 +66,9 @@ public class WalletRechargeRefundDto { * 自动退款配置主键;人工审核退款为空,非空时 Wallet 必须锁定配置并确认开关仍开启。 */ private Long autoRefundConfigId; + + /** + * 自动退款任务场景编码;过期预约任务为 20,空值兼容历史售后免审调用,人工退款不得填写。 + */ + private String autoRefundTaskType; } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java index 7e0529e8..21c5504d 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.extension.activerecord.Model; import com.bsd.pjcloud.common.core.util.TenantTable; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; import lombok.Data; import lombok.EqualsAndHashCode; @@ -84,6 +85,13 @@ public class WalletTransactionEntity extends Model { @Schema(description = "财务统计标签,多个字典值使用英文逗号分隔") private String tags; + /** + * 流水备注,由后台操作人员补充业务说明;历史流水和未填写场景允许为空,数据库最多保存 256 个字符。 + */ + @Size(max = 256, message = "流水备注不能超过256个字符") + @Schema(description = "流水备注,最多256个字符") + private String remark; + /** * 创建人 */ diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchAutoRefundConfigEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchAutoRefundConfigEntity.java index 88f0f4f0..0dd6391a 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchAutoRefundConfigEntity.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchAutoRefundConfigEntity.java @@ -67,6 +67,18 @@ public class MchAutoRefundConfigEntity extends Model @Schema(description = "支付后可自动退款小时数") private Integer payTimeLimitHours; + /** 过期预约订单自动退款开关,持久化值为1开启、0关闭;与用户免审退款开关独立。 */ + @Schema(description = "过期预约自动退款开关") + private String expiredReserveRefundEnabled; + + /** 预约餐段结束后的自动退款延迟小时数,单位小时,允许1至168。 */ + @Schema(description = "过期预约退款延迟小时数") + private Integer expiredReserveRefundDelayHours; + + /** 过期预约退款是否恢复每日菜谱库存,配置保存时快照到订单和任务。 */ + @Schema(description = "过期预约退款是否退库存") + private String expiredReserveRefundRestoreInventory; + /** * 创建人账号,由公共字段填充器在新增时写入。 */ diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchManageEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchManageEntity.java index 89450c98..1b6736d4 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchManageEntity.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchManageEntity.java @@ -2,8 +2,8 @@ package com.bsd.pjcloud.wallet.api.entity.mch; import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.extension.activerecord.Model; -import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; import com.bsd.pjcloud.wallet.api.constant.mch.WalletMchTableReservationMode; +import com.bsd.pjcloud.wallet.api.handler.MchManageJacksonTypeHandler; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; @@ -166,7 +166,7 @@ public class MchManageEntity extends Model { * 功能范围 */ @Schema(description = "功能范围") - @TableField(typeHandler = JacksonTypeHandler.class) + @TableField(typeHandler = MchManageJacksonTypeHandler.class) private FunctionScope functionScope; @@ -174,14 +174,14 @@ public class MchManageEntity extends Model { * 订餐详细配置 */ @Schema(description = "订餐详细配置") - @TableField(typeHandler = JacksonTypeHandler.class) + @TableField(typeHandler = MchManageJacksonTypeHandler.class) private TableReservationConf tableReservationConf; /** * 钱包别名 */ @Schema(description = "钱包别名") - @TableField(typeHandler = JacksonTypeHandler.class) + @TableField(typeHandler = MchManageJacksonTypeHandler.class) private List walletAliasList; /** diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java index b6c96de5..7d87b4e0 100755 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java @@ -57,9 +57,9 @@ public class MchPaySettingEntity extends Model { private String settingJson; /** - * 分账比例(最高100%即分给对方商户的%) + * 平台抽佣比例,单位为百分比,取值范围为大于0且不超过100。 */ - @Schema(description = "分账比例(最高100%即分给对方商户的%)") + @Schema(description = "平台抽佣比例,单位为百分比,取值范围为(0,100]") private Double fzRatio; /** @@ -117,14 +117,19 @@ public class MchPaySettingEntity extends Model { @Data static public class AliPaySetting { /** - * 子商户号 + * 支付宝直付通二级商户标识,保存支付宝分配的 smid/ip_role_id,不是平台应用商户号。 */ private String subMchid; /** - * 子商户授权token + * 支付宝第三方代调用授权令牌;无需代调用授权的账号允许为空。 */ private String appAuthToken; + + /** + * 平台支付宝命名账号键,来源于现有支付配置 JSON;为空时使用平台默认账号。 + */ + private String accountKey; } -} \ No newline at end of file +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteCodeUseService.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteCodeUseService.java index bc68fab3..bd7a9293 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteCodeUseService.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteCodeUseService.java @@ -37,6 +37,16 @@ public interface RemoteCodeUseService { @GetMapping("/wallet-code-use/cancel-code") R cancelCode(@RequestParam Long id); + /** + * 将预约订单二维码恢复为未核销状态,供订单核销纠错时再次使用。 + * + * @param id 预约订单业务主键 + * @return 找到预约二维码且已恢复或原本未使用时返回 {@code true} + */ + @NoToken + @GetMapping("/wallet-code-use/rollback-reservation-code") + R rollbackReservationCode(@RequestParam Long id); + /** * 二维码是否核销 * diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/handler/MchManageJacksonTypeHandler.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/handler/MchManageJacksonTypeHandler.java new file mode 100644 index 00000000..df40dc4a --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/handler/MchManageJacksonTypeHandler.java @@ -0,0 +1,54 @@ +package com.bsd.pjcloud.wallet.api.handler; + +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.bsd.pjcloud.wallet.api.entity.mch.MchManageEntity; +import com.fasterxml.jackson.core.type.TypeReference; + +import java.lang.reflect.Field; +import java.lang.reflect.Type; +import java.util.List; + +/** + * 商户 JSON 字段类型处理器。 + * + *

MyBatis-Plus 自动结果映射会把字段反射信息传给 Jackson,但 XML 结果映射只会传递 + * {@link List} 原始类型。这里为钱包别名列表补充固定元素类型,避免其在不同查询路径下被解析为 Map。

+ */ +public class MchManageJacksonTypeHandler extends JacksonTypeHandler { + + /** + * 钱包别名列表的完整泛型类型,用于兼容 XML 结果映射无法传递字段泛型的场景。 + */ + private static final Type WALLET_ALIAS_LIST_TYPE = new TypeReference>() { + }.getType(); + + /** + * 创建仅持有属性原始类型的处理器,主要供 MyBatis XML 结果映射使用。 + * + * @param type 目标属性原始类型;列表类型会固定解析为钱包别名列表 + */ + public MchManageJacksonTypeHandler(Class type) { + super(type); + } + + /** + * 创建持有实体字段信息的处理器,供 MyBatis-Plus 和 MPJ 自动结果映射使用。 + * + * @param type 目标属性原始类型 + * @param field 实体字段,包含列表元素的泛型信息 + */ + public MchManageJacksonTypeHandler(Class type, Field field) { + super(type, field); + } + + /** + * 获取 Jackson 反序列化目标类型;字段泛型缺失时为钱包别名列表补充元素类型。 + * + * @return 当前商户 JSON 字段的完整目标类型 + */ + @Override + public Type getFieldType() { + Type fieldType = super.getFieldType(); + return List.class.equals(fieldType) ? WALLET_ALIAS_LIST_TYPE : fieldType; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletCodeUseController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletCodeUseController.java index 2d209ba7..eb61a54d 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletCodeUseController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletCodeUseController.java @@ -45,6 +45,18 @@ public class WalletCodeUseController { return R.ok(walletCodeUseService.cancelCode(id)); } + /** + * 恢复预约订单二维码的未核销状态,仅供订单服务处理后台核销纠错。 + * + * @param id 预约订单业务主键 + * @return 找到预约二维码且已恢复或原本未使用时返回 {@code true} + */ + @Inner + @GetMapping("/rollback-reservation-code") + public R rollbackReservationCode(@RequestParam Long id) { + return R.ok(walletCodeUseService.rollbackReservationCode(id)); + } + /** * 是否核销 */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java index d7fd3033..55e41a33 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java @@ -14,6 +14,7 @@ import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum; import com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto; import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto; +import com.bsd.pjcloud.wallet.api.dto.WalletTransactionRemarkDto; import com.bsd.pjcloud.wallet.api.dto.WalletTransactionSumAndCountDto; import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity; import com.bsd.pjcloud.wallet.api.vo.WalletCzTotalVo; @@ -160,10 +161,24 @@ public class WalletTransactionController { @PutMapping("/update-balance") @Operation(summary = "更新会员用户余额") @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_update_balance')") - public R updateWalletBalance(@RequestBody WalletTransactionDto dto) { + public R updateWalletBalance(@Valid @RequestBody WalletTransactionDto dto) { return R.ok(walletTransactionService.updateWalletBalance(dto)); } + /** + * 修改单笔钱包流水的业务备注,不开放金额、余额、业务类型等流水核心字段的变更。 + * + * @param dto 流水编号和新备注,备注为空时清除原值 + * @return 更新到当前租户有效流水时返回 {@code true} + */ + @PutMapping("/update-remark") + @Operation(summary = "修改钱包流水备注") + @SysLog("修改钱包流水备注") + @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_edit')") + public R updateRemark(@Valid @RequestBody WalletTransactionRemarkDto dto) { + return R.ok(walletTransactionService.updateRemark(dto)); + } + @Operation(summary = "后台充值分页", description = "后台充值分页") @PostMapping("/page-cz") public R pageCz(@ParameterObject Page page, @RequestBody WalletTransactionDto dto) { @@ -295,7 +310,7 @@ public class WalletTransactionController { @SysLog("新增会员钱包流水表") @PostMapping @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_add')") - public R save(@RequestBody WalletTransactionEntity walletTransaction) { + public R save(@Valid @RequestBody WalletTransactionEntity walletTransaction) { return R.ok(walletTransactionService.save(walletTransaction)); } @@ -303,7 +318,7 @@ public class WalletTransactionController { @SysLog("修改会员钱包流水表") @PutMapping @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_edit')") - public R updateById(@RequestBody WalletTransactionEntity walletTransaction) { + public R updateById(@Valid @RequestBody WalletTransactionEntity walletTransaction) { return R.ok(walletTransactionService.updateById(walletTransaction)); } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchPaySettingController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchPaySettingController.java index 7f4d700f..6847a892 100755 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchPaySettingController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchPaySettingController.java @@ -68,7 +68,7 @@ public class MchPaySettingController { @GetMapping("/{id}") @PreAuthorize("@pms.hasPermission('wallet_mchPaySetting_view')") public R getById(@PathVariable("id") Long id) { - return R.ok(mchPaySettingService.getById(id)); + return R.ok(mchPaySettingService.getSafeById(id)); } /** diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchAutoRefundConfigService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchAutoRefundConfigService.java index fcc6a22c..de5d8e81 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchAutoRefundConfigService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchAutoRefundConfigService.java @@ -62,4 +62,13 @@ public interface MchAutoRefundConfigService extends IService { + /** + * 按数据权限分页查询可展示的支付配置,并移除客户端不需要的支付宝授权令牌。 + * + * @param page 分页参数 + * @param dto 支付配置筛选条件 + * @param dataScope 当前管理端数据权限 + * @return 已脱敏的支付配置分页 + */ Page scopePageList(Page page, MchPaySettingDto dto, DataScope dataScope); + /** + * 查询服务内部使用的完整支付配置,保留支付调用所需的授权令牌。 + * + * @param dto 支付配置筛选条件 + * @param dataScope 可选数据权限 + * @return 服务内部支付配置列表 + */ List pageList(MchPaySettingDto dto, DataScope dataScope); + /** + * 按主键获取管理端可编辑配置,支付宝授权令牌不会返回浏览器。 + * + * @param id 支付配置主键 + * @return 已脱敏的支付配置;不存在时返回 {@code null} + */ + MchPaySettingEntity getSafeById(Long id); + + /** + * 新增商户支付配置并通知第三方支付服务刷新商户快照。 + * + * @param dto 新增配置 + * @return 新增记录主键 + */ @CacheEvict( value = CacheConstants.WALLET_VIEWS_CANTEEN_MCH, allEntries = true, @@ -25,6 +57,12 @@ public interface MchPaySettingService extends IService { ) Long create(MchPaySettingDto dto); + /** + * 修改支付配置;支付宝授权令牌留空时保留已存值,返回结果不包含完整令牌。 + * + * @param dto 修改配置 + * @return 已脱敏的修改结果 + */ @CacheEvict( value = CacheConstants.WALLET_VIEWS_CANTEEN_MCH, condition = "#dto.getMchId() != null", @@ -34,7 +72,10 @@ public interface MchPaySettingService extends IService { MchPaySettingEntity edit(MchPaySettingDto dto); /** - * 用户可以支付的方式(目前微信、支付宝) + * 查询用户可用的微信、支付宝支付方式,返回客户端前移除支付宝授权令牌。 + * + * @param user 当前登录用户 + * @return 已脱敏的可用支付配置 */ List getUserPaySettingList(PjcloudUser user); -} \ No newline at end of file +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletCodeUseService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletCodeUseService.java index b5fb4d16..d7080fc0 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletCodeUseService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletCodeUseService.java @@ -27,6 +27,14 @@ public interface WalletCodeUseService extends IService { */ Boolean cancelCode(Long id); + /** + * 恢复预约订单二维码的未核销状态,不允许影响钱包码、活动码等其他业务二维码。 + * + * @param id 预约订单业务主键 + * @return 找到预约二维码且已恢复或原本未使用时返回 {@code true} + */ + Boolean rollbackReservationCode(Long id); + /** * 二维码是否核销 */ @@ -41,4 +49,4 @@ public interface WalletCodeUseService extends IService { * 根据短码查询加密信息 */ WalletCodeUseEntity getInfoByShortCode(String shortCode); -} \ No newline at end of file +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java index bc9ca188..6dbf4961 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java @@ -6,6 +6,7 @@ import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum; import com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto; import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto; +import com.bsd.pjcloud.wallet.api.dto.WalletTransactionRemarkDto; import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity; import com.bsd.pjcloud.wallet.api.vo.WalletCzTotalVo; import com.bsd.pjcloud.wallet.api.vo.WalletTypeTotalVo; @@ -98,6 +99,17 @@ public interface WalletTransactionService extends IService 0; } + /** + * 将预约订单对应的最新二维码恢复为未使用状态。业务类型白名单用于防止订单 ID 与其他二维码业务 ID + * 偶然相同后误恢复钱包码或活动码;条件更新用于和并发核销串行竞争,避免覆盖新的使用结果。 + * + * @param id 预约订单业务主键 + * @return 找到预约二维码且已恢复或原本未使用时返回 {@code true} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean rollbackReservationCode(Long id) { + WalletCodeUseEntity codeUse = baseMapper.selectOne(new LambdaQueryWrapper() + .eq(WalletCodeUseEntity::getBizId, id) + .in(WalletCodeUseEntity::getBizType, + WalletCodeBizEnum.CANTEEN_RESERVE.getCode(), WalletCodeBizEnum.SUPERMARKET_RESERVE.getCode()) + .orderByDesc(WalletCodeUseEntity::getCreateTime) + .orderByDesc(WalletCodeUseEntity::getId) + .last("limit 1")); + if (ObjectUtil.isEmpty(codeUse)) return false; + if (ObjectUtil.equals(codeUse.getStatus(), YesNoEnum.NO.getCode())) return true; + if (ObjectUtil.notEqual(codeUse.getStatus(), YesNoEnum.YES.getCode())) return false; + return baseMapper.update(null, new LambdaUpdateWrapper() + .set(WalletCodeUseEntity::getStatus, YesNoEnum.NO.getCode()) + .set(WalletCodeUseEntity::getUseTime, null) + .eq(WalletCodeUseEntity::getId, codeUse.getId()) + .eq(WalletCodeUseEntity::getStatus, YesNoEnum.YES.getCode())) == 1; + } + @Override @Transactional(rollbackFor = Exception.class) public Boolean isCancelCode(Long id) { @@ -283,4 +311,4 @@ public class WalletCodeUseServiceImpl extends ServiceImpl walletManageMapper.updateWhenRecharge(walletId, price); + case REFUND, TRANSFER_ACCOUNTS_REFUND, RETURN_CANTEEN -> walletManageMapper.updateWhenConsumptionRefund(walletId, price); + default -> throw new UnsupportedOperationException("待实现"); + } + WalletTransactionEntity transaction = buildWalletTransaction(wallet, walletId, price, + wallet.getBalance() + price, bizId, bizType); + save(transaction); + return transaction; + } + /** * 增加钱包余额并将来源业务的财务标签同步到最终流水,供后续列表筛选和金额统计。 * @@ -683,6 +719,23 @@ public class WalletTransactionServiceImpl extends ServiceImpllambdaUpdate() + .eq(WalletTransactionEntity::getId, dto.getId()) + .set(WalletTransactionEntity::getRemark, remark)); + // 部分 MySQL affected-row 配置会把“值未变化”返回为 0,仅在记录也查不到时才判定失败。 + if (updated == 0 && baseMapper.selectById(dto.getId()) == null) { + throw new RuntimeException("钱包流水不存在或已删除"); + } + return true; + } + @Override // @CacheEvict(value = CacheConstants.WALLET_DETAIL, key = "#dto.getWalletId()") public Boolean updateWalletBalance(WalletTransactionDto dto) { @@ -729,7 +809,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl MAX_PAY_TIME_LIMIT_HOURS) { + throw new RuntimeException("过期预约退款延迟必须在1至168小时之间"); + } + if (expiredRestoreInventory != null && !Objects.equals(expiredRestoreInventory, YesNoEnum.YES.getCode()) + && !Objects.equals(expiredRestoreInventory, YesNoEnum.NO.getCode())) { + throw new RuntimeException("过期预约退库存开关值无效"); + } + } + + /** 解析过期退款延迟,兼容历史配置行新增字段为空的场景。 */ + private int resolveExpiredDelay(Integer value) { + return value == null ? DEFAULT_EXPIRED_RESERVE_DELAY_HOURS : value; + } + + /** 解析开关默认值,保证历史配置读取后具备稳定协议值。 */ + private String resolveYesNo(String value, String defaultValue) { + return value == null ? defaultValue : value; } /** diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java index 8b65393b..1d70f7ac 100755 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java @@ -1,6 +1,8 @@ package com.bsd.pjcloud.wallet.service.impl.mch; +import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; @@ -11,6 +13,7 @@ import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppPlusPayService; import com.bsd.pjcloud.wallet.api.dto.mch.MchPaySettingDto; import com.bsd.pjcloud.wallet.api.entity.mch.MchManageEntity; @@ -49,7 +52,9 @@ public class MchPaySettingServiceImpl extends ServiceImpl scopePageList(Page page, MchPaySettingDto dto, DataScope dataScope) { - return baseMapper.scopePageList(page, dto, dataScope); + Page result = baseMapper.scopePageList(page, dto, dataScope); + result.setRecords(result.getRecords().stream().map(this::sanitizeForClient).toList()); + return result; } @Override @@ -57,6 +62,18 @@ public class MchPaySettingServiceImpl extends ServiceImpl { try { @@ -92,7 +110,7 @@ public class MchPaySettingServiceImpl extends ServiceImpl= 1; } -} \ No newline at end of file + /** + * 管理端因安全原因读取不到旧 token,提交空 token 时应保留数据库中的已配置值。 + * + * @param dto 管理端提交的支付配置 + */ + private void preserveAliPayAuthToken(MchPaySettingDto dto) { + if (dto == null || dto.getId() == null || !AppPlusPlatformPayType.ALI_PAY.getType().equals(dto.getPlatformType()) + || StrUtil.isBlank(dto.getSettingJson())) { + return; + } + MchPaySettingEntity existing = getById(dto.getId()); + if (existing == null || StrUtil.isBlank(existing.getSettingJson())) { + return; + } + try { + MchPaySettingEntity.AliPaySetting incoming = JSONUtil.toBean( + dto.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + MchPaySettingEntity.AliPaySetting stored = JSONUtil.toBean( + existing.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + if (incoming != null && stored != null && StrUtil.isBlank(incoming.getAppAuthToken()) + && StrUtil.isNotBlank(stored.getAppAuthToken())) { + incoming.setAppAuthToken(stored.getAppAuthToken()); + dto.setSettingJson(JSONUtil.toJsonStr(incoming)); + } + } catch (RuntimeException exception) { + throw new RuntimeException("支付宝支付配置无效"); + } + } + + /** + * 复制并脱敏支付配置,保证调用方不能通过返回对象修改缓存或内部商户配置。 + * + * @param source 内部完整支付配置 + * @param 支付配置实体或视图类型 + * @return 同类型的脱敏副本 + */ + @SuppressWarnings("unchecked") + private T sanitizeForClient(T source) { + if (source == null) { + return null; + } + T target = (T) BeanUtil.toBean(source, source.getClass()); + if (!AppPlusPlatformPayType.ALI_PAY.getType().equals(target.getPlatformType()) || StrUtil.isBlank(target.getSettingJson())) { + return target; + } + try { + MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean( + target.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + if (aliPaySetting != null) { + aliPaySetting.setAppAuthToken(null); + target.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); + } + } catch (RuntimeException exception) { + // 无法解析的历史配置不应原样下发,以免其中包含未识别的敏感字段。 + target.setSettingJson(null); + } + return target; + } + +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargePackageServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargePackageServiceImpl.java index 8022f66c..ed1292ab 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargePackageServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargePackageServiceImpl.java @@ -90,8 +90,9 @@ public class WalletRechargePackageServiceImpl extends ServiceImpl + @@ -24,6 +25,7 @@ a.price, a.balance, a.tags, + a.remark, a.create_by, a.create_time, a.update_by, diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchManageMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchManageMapper.xml index d3835ef2..3fcbcbfd 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchManageMapper.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchManageMapper.xml @@ -3,6 +3,21 @@ + + + + + + + select a.id, a.parent_id, @@ -27,6 +42,8 @@ a.cooperation_status, a.mch_count, a.function_scope, + a.table_reservation_conf, + a.wallet_alias_list, a.create_by, a.create_time, a.update_by, @@ -102,7 +119,7 @@
- c.name as catgoryName from mch_manage a @@ -113,7 +130,7 @@ order by a.sort desc , a.dept_id desc , a.create_time desc - c.name as catgoryName from mch_manage a @@ -123,4 +140,4 @@ order by a.sort desc , a.dept_id desc , a.create_time desc - \ No newline at end of file + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/api/handler/MchManageJacksonTypeHandlerTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/api/handler/MchManageJacksonTypeHandlerTest.java new file mode 100644 index 00000000..881c4618 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/api/handler/MchManageJacksonTypeHandlerTest.java @@ -0,0 +1,45 @@ +package com.bsd.pjcloud.wallet.api.handler; + +import com.bsd.pjcloud.wallet.api.entity.mch.MchManageEntity; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +/** + * 商户 JSON 字段在自动映射和 XML 映射下的目标类型测试。 + */ +class MchManageJacksonTypeHandlerTest { + + /** + * XML 映射只传入 List 原始类型时,元素仍应反序列化为钱包别名对象而不是 Map。 + */ + @Test + void rawListTypeShouldDeserializeWalletAliasElements() { + MchManageJacksonTypeHandler handler = new MchManageJacksonTypeHandler(List.class); + + Object value = handler.parse("[{\"walletTypeId\":10,\"walletUniqueCode\":\"EAT\",\"visible\":true,\"aliasName\":\"餐补\"}]"); + + List aliases = assertInstanceOf(List.class, value); + MchManageEntity.WalletAlias alias = assertInstanceOf(MchManageEntity.WalletAlias.class, aliases.get(0)); + assertEquals(10L, alias.getWalletTypeId()); + assertEquals("EAT", alias.getWalletUniqueCode()); + assertEquals("餐补", alias.getAliasName()); + } + + /** + * 普通对象字段应继续按 XML 显式传入的目标 Class 反序列化。 + */ + @Test + void concreteTypeShouldDeserializeConfiguredClass() { + MchManageJacksonTypeHandler handler = new MchManageJacksonTypeHandler(MchManageEntity.FunctionScope.class); + + Object value = handler.parse("{\"mallEnable\":true,\"tableReservation\":false}"); + + MchManageEntity.FunctionScope functionScope = assertInstanceOf(MchManageEntity.FunctionScope.class, value); + assertEquals(Boolean.TRUE, functionScope.getMallEnable()); + assertEquals(Boolean.FALSE, functionScope.getTableReservation()); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletCodeUseServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletCodeUseServiceImplTest.java new file mode 100644 index 00000000..9acb6286 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletCodeUseServiceImplTest.java @@ -0,0 +1,92 @@ +package com.bsd.pjcloud.wallet.service.impl; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.wallet.api.constant.WalletCodeBizEnum; +import com.bsd.pjcloud.wallet.api.entity.WalletCodeUseEntity; +import com.bsd.pjcloud.wallet.mapper.WalletCodeUseMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 预约二维码核销状态回退测试,确保订单纠错不会误改其他二维码业务。 + */ +class WalletCodeUseServiceImplTest { + + /** 二维码 Mapper Mock,用于返回目标预约码并核对条件更新。 */ + private final WalletCodeUseMapper walletCodeUseMapper = mock(WalletCodeUseMapper.class); + + /** 被测二维码服务,仅执行本次回退方法的真实实现。 */ + private final WalletCodeUseServiceImpl service = mock(WalletCodeUseServiceImpl.class, CALLS_REAL_METHODS); + + /** + * 将 MyBatis-Plus 基类 Mapper 指向当前 Mock,避免启动 Spring 与数据库。 + */ + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "baseMapper", walletCodeUseMapper); + } + + /** + * 验证已使用的食堂预约码会恢复为未使用状态。 + */ + @Test + void rollbackReservationCodeRestoresConsumedCode() { + when(walletCodeUseMapper.selectOne(any(Wrapper.class))).thenReturn(reservationCode(YesNoEnum.YES)); + when(walletCodeUseMapper.update(isNull(), any(Wrapper.class))).thenReturn(1); + + assertTrue(service.rollbackReservationCode(1L)); + + verify(walletCodeUseMapper).update(isNull(), any(Wrapper.class)); + } + + /** + * 验证已经处于未使用状态的预约码按幂等成功处理,不重复写库。 + */ + @Test + void rollbackReservationCodeAcceptsAlreadyPendingCode() { + when(walletCodeUseMapper.selectOne(any(Wrapper.class))).thenReturn(reservationCode(YesNoEnum.NO)); + + assertTrue(service.rollbackReservationCode(1L)); + + verify(walletCodeUseMapper, never()).update(isNull(), any(Wrapper.class)); + } + + /** + * 验证不存在预约码时明确返回失败,阻止 Hardware 单独回退订单状态。 + */ + @Test + void rollbackReservationCodeRejectsMissingCode() { + when(walletCodeUseMapper.selectOne(any(Wrapper.class))).thenReturn(null); + + assertFalse(service.rollbackReservationCode(1L)); + + verify(walletCodeUseMapper, never()).update(isNull(), any(Wrapper.class)); + } + + /** + * 构造指定使用状态的食堂预约二维码记录。 + * + * @param status 二维码是否已使用的协议枚举 + * @return 固定业务 ID 的预约二维码记录 + */ + private WalletCodeUseEntity reservationCode(YesNoEnum status) { + return WalletCodeUseEntity.builder() + .id(10L) + .bizId(1L) + .bizType(WalletCodeBizEnum.CANTEEN_RESERVE.getCode()) + .status(status.getCode()) + .build(); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImplTest.java index 776c6068..6128b81b 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImplTest.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImplTest.java @@ -1,13 +1,21 @@ package com.bsd.pjcloud.wallet.service.impl; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.bsd.pjcloud.admin.api.feign.RemoteUserRelationService; import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum; import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum; import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto; import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; +import com.bsd.pjcloud.wallet.api.entity.WalletMessageOutboxEntity; +import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity; import com.bsd.pjcloud.wallet.api.vo.transaction.WalletTransactionVo; +import com.bsd.pjcloud.wallet.mapper.WalletManageMapper; +import com.bsd.pjcloud.wallet.mapper.WalletMessageOutboxMapper; import com.bsd.pjcloud.wallet.mapper.WalletTransactionMapper; +import com.bsd.pjcloud.wallet.mapper.WalletTypeMapper; +import com.bsd.pjcloud.wallet.service.MchManageService; import com.bsd.pjcloud.wallet.service.WalletManageService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -47,6 +55,22 @@ class WalletTransactionServiceImplTest { @Mock private WalletTransactionMapper walletTransactionMapper; + /** 钱包 Mapper Mock,用于模拟钱包行锁和余额原子更新。 */ + @Mock + private WalletManageMapper walletManageMapper; + + /** 钱包消息 Outbox Mapper Mock,用于验证首次入账同步创建一条待投递事件。 */ + @Mock + private WalletMessageOutboxMapper walletMessageOutboxMapper; + + /** 钱包类型 Mapper Mock,用于满足 Outbox 钱包名称快照解析。 */ + @Mock + private WalletTypeMapper walletTypeMapper; + + /** 商户服务 Mock,用于满足 Outbox 来源商户快照解析。 */ + @Mock + private MchManageService mchManageService; + /** 被测钱包流水服务,其余与当前授权场景无关的依赖由 Mockito 注入空值。 */ @InjectMocks private WalletTransactionServiceImpl service; @@ -125,6 +149,57 @@ class WalletTransactionServiceImplTest { verify(walletManageService, never()).getWallet(any(), any(WalletTypeEnum.class)); } + /** + * 首次幂等入账应锁定钱包、增加一次余额并保存稳定业务号流水。 + */ + @Test + void idempotentCreditShouldUpdateBalanceOnce() { + WalletManageEntity wallet = wallet(20L, 1L); + wallet.setDeptId(2L); + wallet.setBalance(100); + when(walletManageMapper.selectByIdForUpdate(20L)).thenReturn(wallet); + when(walletTransactionMapper.selectOne(any(Wrapper.class))).thenReturn(null); + when(walletManageMapper.updateWhenRecharge(20L, 50)).thenReturn(1); + when(walletTransactionMapper.insert(any(WalletTransactionEntity.class))).thenAnswer(invocation -> { + WalletTransactionEntity transaction = invocation.getArgument(0); + transaction.setId(1001L); + transaction.setTenantId(1L); + return 1; + }); + when(walletManageMapper.selectById(20L)).thenReturn(wallet); + when(walletMessageOutboxMapper.insert(any(WalletMessageOutboxEntity.class))).thenReturn(1); + + WalletTransactionEntity result = service.addWalletBalanceIdempotent(20L, 50, "pay-1001", WalletBizEnum.RECHARGE); + + assertEquals(50, result.getPrice()); + assertEquals(150, result.getBalance()); + assertEquals("pay-1001", result.getBizId()); + verify(walletManageMapper).updateWhenRecharge(20L, 50); + verify(walletTransactionMapper).insert(any(WalletTransactionEntity.class)); + verify(walletMessageOutboxMapper).insert(any(WalletMessageOutboxEntity.class)); + } + + /** + * 已存在金额一致的充值流水应直接返回,不再次修改钱包余额。 + */ + @Test + void repeatedIdempotentCreditShouldReturnExistingTransaction() { + WalletManageEntity wallet = wallet(20L, 1L); + wallet.setBalance(150); + WalletTransactionEntity existing = new WalletTransactionEntity(); + existing.setWalletId(20L); + existing.setPrice(50); + existing.setBizId("pay-1001"); + existing.setBizType(WalletBizEnum.RECHARGE.getCode()); + when(walletManageMapper.selectByIdForUpdate(20L)).thenReturn(wallet); + when(walletTransactionMapper.selectOne(any(Wrapper.class))).thenReturn(existing); + + assertSame(existing, service.addWalletBalanceIdempotent(20L, 50, "pay-1001", WalletBizEnum.RECHARGE)); + + verify(walletManageMapper, never()).updateWhenRecharge(any(), any()); + verify(walletTransactionMapper, never()).insert(any(WalletTransactionEntity.class)); + } + /** * 构造指定所有人和 ID 的 EAT 饭卡。 * diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java new file mode 100644 index 00000000..11e14279 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java @@ -0,0 +1,123 @@ +package com.bsd.pjcloud.wallet.service.impl.mch; + +import cn.hutool.json.JSONUtil; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppPlusPayService; +import com.bsd.pjcloud.wallet.api.dto.mch.MchPaySettingDto; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.bsd.pjcloud.wallet.mapper.mch.MchPaySettingMapper; +import com.bsd.pjcloud.wallet.service.MchManageService; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 商户支付配置支付宝授权令牌脱敏和更新保留测试。 + */ +@ExtendWith(MockitoExtension.class) +class MchPaySettingServiceImplTest { + + /** 支付配置 Mapper Mock,提供数据库中的完整配置。 */ + @Mock + private MchPaySettingMapper mchPaySettingMapper; + + /** 商户服务 Mock,仅补齐异步刷新依赖。 */ + @Mock + private MchManageService mchManageService; + + /** 用户远程服务 Mock,仅补齐构造依赖。 */ + @Mock + private RemoteUserService remoteUserService; + + /** 第三方支付远程服务 Mock,仅补齐异步刷新依赖。 */ + @Mock + private RemoterThirdAppPlusPayService thirdAppPlusPayService; + + /** 被测支付配置服务。 */ + @InjectMocks + private MchPaySettingServiceImpl service; + + /** + * 将 MyBatis-Plus 基类 Mapper 指向当前 Mock。 + */ + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "baseMapper", mchPaySettingMapper); + } + + /** + * 验证管理端详情保留 smid 和账号键,但不返回完整授权令牌。 + */ + @Test + void getSafeByIdShouldRemoveAliPayAuthToken() { + MchPaySettingEntity stored = storedAliPaySetting(); + when(mchPaySettingMapper.selectById(stored.getId())).thenReturn(stored); + + MchPaySettingEntity result = service.getSafeById(stored.getId()); + + MchPaySettingEntity.AliPaySetting safe = JSONUtil.toBean( + result.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + Assertions.assertEquals("smid-1001", safe.getSubMchid()); + Assertions.assertEquals("secondary", safe.getAccountKey()); + Assertions.assertNull(safe.getAppAuthToken()); + Assertions.assertTrue(stored.getSettingJson().contains("sensitive-auth-token")); + } + + /** + * 验证前端提交空 token 时保存旧值,但接口返回值仍保持脱敏。 + */ + @Test + void editShouldPreserveStoredAliPayAuthTokenWithoutReturningIt() { + MchPaySettingEntity stored = storedAliPaySetting(); + when(mchPaySettingMapper.selectCount(any())).thenReturn(1L); + when(mchPaySettingMapper.selectById(stored.getId())).thenReturn(stored); + MchPaySettingEntity.AliPaySetting incomingSetting = new MchPaySettingEntity.AliPaySetting(); + incomingSetting.setSubMchid("smid-1001"); + incomingSetting.setAccountKey("secondary"); + incomingSetting.setAppAuthToken(""); + MchPaySettingDto incoming = new MchPaySettingDto(); + incoming.setId(stored.getId()); + incoming.setMchId(stored.getMchId()); + incoming.setPlatformType("20"); + incoming.setSettingJson(JSONUtil.toJsonStr(incomingSetting)); + + MchPaySettingEntity result = service.edit(incoming); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(MchPaySettingEntity.class); + verify(mchPaySettingMapper).updateById(updateCaptor.capture()); + MchPaySettingEntity.AliPaySetting persisted = JSONUtil.toBean( + updateCaptor.getValue().getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + Assertions.assertEquals("sensitive-auth-token", persisted.getAppAuthToken()); + MchPaySettingEntity.AliPaySetting safeResult = JSONUtil.toBean( + result.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + Assertions.assertNull(safeResult.getAppAuthToken()); + } + + /** + * 构造数据库中带完整支付宝授权令牌的支付配置。 + * + * @return 完整内部配置 + */ + private MchPaySettingEntity storedAliPaySetting() { + MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting(); + aliPaySetting.setSubMchid("smid-1001"); + aliPaySetting.setAccountKey("secondary"); + aliPaySetting.setAppAuthToken("sensitive-auth-token"); + MchPaySettingEntity stored = new MchPaySettingEntity(); + stored.setId(1L); + stored.setMchId(88L); + stored.setPlatformType("20"); + stored.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); + return stored; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImplTest.java index bd7e64fe..f455fafe 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImplTest.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImplTest.java @@ -123,6 +123,27 @@ class WalletRechargeServiceImplTest { locks.verify(configService).requireEnabledForUpdate(30L); } + /** + * 过期预约任务必须使用独立开关终审,不能因为用户免审退款开关关闭而误拒绝或放行。 + */ + @Test + void expiredReserveRefundShouldUseIndependentSwitch() { + MchAutoRefundConfigEntity config = new MchAutoRefundConfigEntity(); + config.setId(30L); + config.setEnabled(YesNoEnum.NO.getCode()); + config.setExpiredReserveRefundEnabled(YesNoEnum.YES.getCode()); + when(configService.lockForUpdate(30L)).thenReturn(config); + when(configService.requireEnabledForUpdate(30L, + WalletRechargeRefundDto.AUTO_REFUND_TASK_TYPE_EXPIRED_RESERVE)).thenReturn(config); + when(rechargeMapper.selectOne(any())).thenReturn(null); + when(rechargeMapper.insert(any(WalletRechargeEntity.class))).thenReturn(1); + + service.refund(refundRequest(100, 30L, WalletRechargeRefundDto.AUTO_REFUND_TASK_TYPE_EXPIRED_RESERVE)); + + verify(configService).requireEnabledForUpdate(30L, + WalletRechargeRefundDto.AUTO_REFUND_TASK_TYPE_EXPIRED_RESERVE); + } + /** * 开关关闭后,已按同一售后单到账的请求仍应返回原记录,不再次终审开关或增加余额。 */ @@ -198,8 +219,21 @@ class WalletRechargeServiceImplTest { * @return 钱包退款请求 */ private WalletRechargeRefundDto refundRequest(Integer price, Long configId) { + return refundRequest(price, configId, null); + } + + /** + * 构造带自动任务场景的退款请求。 + * + * @param price 退款金额,单位为分 + * @param configId 自动退款配置 ID;人工流程为空 + * @param taskType 自动退款场景编码 + * @return 钱包退款请求 + */ + private WalletRechargeRefundDto refundRequest(Integer price, Long configId, String taskType) { return WalletRechargeRefundDto.builder().walletId(20L).orderId(40L).afterSaleId(50L) - .refundCode("hard_40_50").reason("订单退款").price(price).autoRefundConfigId(configId).build(); + .refundCode("hard_40_50").reason("订单退款").price(price).autoRefundConfigId(configId) + .autoRefundTaskType(taskType).build(); } /**