From af9fece0c0223050768a6e6c21ebfcfc608fe5b4 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 25 Aug 2026 12:49:36 +0800 Subject: [PATCH 01/31] =?UTF-8?q?feat:hard=E3=80=81wallet=E8=A1=A8?= =?UTF-8?q?=E6=A0=BC=E6=B7=BB=E5=8A=A0=E4=BA=BA=E5=91=98=E3=80=81=E4=BA=BA?= =?UTF-8?q?=E5=91=98=E8=BA=AB=E4=BB=BD=E5=AD=97=E6=AE=B5=E5=92=8C=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=20fix:=E4=BC=98=E5=8C=96hard=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/20260825_add_third_mch_fz_relation.sql | 36 +- .../bsd/pjcloud/hard/api/dto/HardAuthDto.java | 21 +- .../hard/api/dto/HardBroadcastDto.java | 21 +- .../pjcloud/hard/api/dto/HardDoorFlowDto.java | 21 +- .../pjcloud/hard/api/dto/HardInvoiceDto.java | 17 +- .../hard/api/dto/order/HardAfterSaleDto.java | 17 +- .../hard/api/dto/order/HardOrderDto.java | 18 +- .../hard/api/dto/spu/HardSpuRepertoryDto.java | 18 +- .../bsd/pjcloud/hard/api/vo/HardAuthVo.java | 17 +- .../pjcloud/hard/api/vo/HardBroadcastVo.java | 17 +- .../pjcloud/hard/api/vo/HardDoorFlowVo.java | 17 +- .../pjcloud/hard/api/vo/HardInvoiceVo.java | 26 +- .../hard/api/vo/order/HardAfterSaleVo.java | 26 +- .../HardCanteenProductApptExportRecord.java | 5 + .../hard/api/vo/order/HardOrderVo.java | 15 +- .../hard/api/vo/spu/HardSpuRepertoryVo.java | 53 +- .../order/HardCanteenApptTotalController.java | 117 --- .../order/HardCanteenDeptTotalController.java | 26 +- .../order/HardCanteenTotalController.java | 35 +- ...dCanteenProductApptExportMergeHandler.java | 16 +- .../hard/mapper/HardDoorFlowMapper.java | 3 +- .../hard/mapper/HardInvoiceMapper.java | 2 +- .../hard/mapper/order/HardCarMapper.java | 29 +- .../HardCanteenApptTotalExportService.java | 18 - .../hard/service/HardCanteenTotalService.java | 17 + .../service/impl/HardAuthServiceImpl.java | 27 +- .../impl/HardBroadcastServiceImpl.java | 25 +- .../service/impl/HardDoorFlowServiceImpl.java | 27 +- .../service/impl/HardInvoiceServiceImpl.java | 30 +- .../impl/order/HardAfterSaleServiceImpl.java | 39 +- ...HardCanteenApptTotalExportServiceImpl.java | 497 +-------- .../order/HardCanteenTotalServiceImpl.java | 972 +++++++++++++++--- .../impl/order/HardOrderServiceImpl.java | 44 +- .../impl/spu/HardSpuRepertoryServiceImpl.java | 33 +- .../main/resources/mapper/HardAuthMapper.xml | 8 +- .../resources/mapper/HardBroadcastMapper.xml | 8 +- .../resources/mapper/HardInvoiceMapper.xml | 2 +- .../mapper/order/HardAfterSaleMapper.xml | 2 +- .../resources/mapper/order/HardCarMapper.xml | 251 +++-- ...teenProductApptExportMergeHandlerTest.java | 34 +- ...CanteenApptTotalExportServiceImplTest.java | 163 +-- .../HardCanteenTotalServiceImplTest.java | 207 +++- .../pay/ThirdPaymentTaskTypeEnum.java | 2 + .../pay/wechat/ThirdAppPlusPayDto.java | 5 + .../entity/pay/ThirdPayOrderEntity.java | 7 +- .../entity/pay/ThirdPaymentTaskEntity.java | 2 + .../thirdparty/config/AppPlusPayConfig.java | 10 + .../appplus/ThirdAppPlusController.java | 29 +- .../mapper/pay/ThirdPaymentTaskMapper.java | 12 + .../pay/ThirdAggregatePayCreateContext.java | 3 + .../service/ThirdMchFzRelationService.java | 8 + .../service/ThirdPaymentTaskEnqueuer.java | 46 + .../ThirdAggregateAliPayServiceImpl.java | 28 + .../ThirdAggregateOrderPayServiceImpl.java | 29 +- .../ThirdAggregateWechatPayServiceImpl.java | 12 + .../ThirdAppPlusAliPayServiceImpl.java | 100 +- .../impl/appplus/ThirdAppPlusServiceImpl.java | 30 + ...ThirdAppPlusWechatMicropayServiceImpl.java | 4 + ...hirdAppPlusWechatNativePayServiceImpl.java | 12 + .../ThirdAppPlusWechatServiceImpl.java | 4 + .../fz/ThirdAppPlusWechatFzServiceImpl.java | 47 +- .../pay/ThirdMchFzRelationServiceImpl.java | 353 ++++++- .../impl/pay/ThirdPaymentTaskServiceImpl.java | 12 + .../resources/mapper/ThirdPayOrderMapper.xml | 1 + .../mapper/ThirdPaymentTaskMapper.xml | 28 +- ...ThirdPaymentTaskMapperSqlContractTest.java | 18 + .../service/ThirdPaymentTaskEnqueuerTest.java | 26 + .../ThirdAggregateAliPayServiceImplTest.java | 24 +- ...ThirdAggregateOrderPayServiceImplTest.java | 8 +- .../ThirdAppPlusAliPayServiceImplTest.java | 48 + ...irdAppPlusServiceImplOrderCodePayTest.java | 24 + .../ThirdAppPlusWechatFzServiceImplTest.java | 81 ++ .../config/ThirdPaymentQuartzInitializer.java | 1 + .../wallet/api/dto/WalletActiveDto.java | 17 +- .../wallet/api/dto/WalletForeignDto.java | 19 +- .../wallet/api/dto/WalletFreezeDto.java | 17 +- .../api/dto/WalletManageBalanceDto.java | 18 +- .../wallet/api/dto/WalletManageDto.java | 20 +- .../wallet/api/dto/WalletStrideDto.java | 17 +- .../wallet/api/dto/WalletTopupActionDto.java | 20 +- .../wallet/api/dto/WalletTransactionDto.java | 18 +- .../dto/recharge/WalletRechargeApplyDto.java | 17 +- .../WalletRechargeBindInvoiceDto.java | 17 +- .../dto/recharge/WalletRechargeImportDto.java | 18 +- .../dto/rule/WalletRuleTransactionDto.java | 20 +- .../pjcloud/wallet/api/vo/WalletActiveVo.java | 27 +- .../wallet/api/vo/WalletForeignVo.java | 27 +- .../pjcloud/wallet/api/vo/WalletFreezeVo.java | 27 +- .../pjcloud/wallet/api/vo/WalletManageVo.java | 62 +- .../pjcloud/wallet/api/vo/WalletStrideVo.java | 27 +- .../vo/recharge/WalletRechargeApplyVo.java | 27 +- .../vo/recharge/WalletRechargeImportVo.java | 19 +- .../vo/recharge/WalletRechargeInvoiceVo.java | 38 +- .../api/vo/rule/WalletRuleTransactionVo.java | 19 +- .../vo/transaction/WalletTransactionVo.java | 62 +- .../WalletTransactionController.java | 5 + .../mch/MchPaySettingController.java | 3 +- .../WalletRechargeImportController.java | 3 +- .../wallet/mapper/WalletActiveMapper.java | 4 +- .../wallet/mapper/WalletFreezeMapper.java | 4 +- .../recharge/WalletRechargeImportMapper.java | 6 +- .../wallet/service/MchPaySettingService.java | 8 + .../wallet/service/WalletManageService.java | 7 + .../service/WalletRechargeImportService.java | 9 +- .../service/WalletTransactionService.java | 7 + .../service/impl/WalletActiveServiceImpl.java | 39 +- .../impl/WalletForeignServiceImpl.java | 34 +- .../service/impl/WalletFreezeServiceImpl.java | 34 +- .../impl/WalletManageExportServiceImpl.java | 1 + .../service/impl/WalletManageServiceImpl.java | 57 +- .../service/impl/WalletStrideServiceImpl.java | 36 +- .../impl/WalletTransactionServiceImpl.java | 36 +- .../impl/mch/MchPaySettingServiceImpl.java | 68 +- .../WalletRechargeApplyServiceImpl.java | 41 +- .../WalletRechargeImportServiceImpl.java | 58 +- .../WalletRechargeInvoiceServiceImpl.java | 28 +- .../WalletRuleTransactionServiceImpl.java | 33 +- .../support/WalletUserIdentitySupport.java | 257 +++++ .../resources/mapper/WalletManageMapper.xml | 20 +- .../recharge/WalletRechargeInvoiceMapper.xml | 26 +- .../rule/WalletRuleTransactionMapper.xml | 8 +- .../mch/MchPaySettingServiceImplTest.java | 7 +- 122 files changed, 3963 insertions(+), 1420 deletions(-) create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/WalletUserIdentitySupport.java diff --git a/db/20260825_add_third_mch_fz_relation.sql b/db/20260825_add_third_mch_fz_relation.sql index 25115867..6ffc39cc 100644 --- a/db/20260825_add_third_mch_fz_relation.sql +++ b/db/20260825_add_third_mch_fz_relation.sql @@ -54,13 +54,29 @@ SET @relation_task_column_exists = ( ); 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`)', + 'ALTER TABLE `third_payment_task` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''商户分账关系主键'' AFTER `fz_order_id`', 'SELECT 1' ); PREPARE relation_task_column_stmt FROM @relation_task_column_sql; EXECUTE relation_task_column_stmt; DEALLOCATE PREPARE relation_task_column_stmt; +SET @relation_task_index_exists = ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'third_payment_task' + AND INDEX_NAME = 'idx_third_payment_task_relation' +); +SET @relation_task_index_sql = IF( + @relation_task_index_exists = 0, + 'ALTER TABLE `third_payment_task` ADD INDEX `idx_third_payment_task_relation` (`tenant_id`, `fz_relation_id`)', + 'SELECT 1' +); +PREPARE relation_task_index_stmt FROM @relation_task_index_sql; +EXECUTE relation_task_index_stmt; +DEALLOCATE PREPARE relation_task_index_stmt; + -- 支付订单固定引用创建时使用的已绑定关系,后续分账不跟随平台接收账号配置漂移。 SET @pay_relation_column_exists = ( SELECT COUNT(1) @@ -71,9 +87,25 @@ SET @pay_relation_column_exists = ( ); 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`)', + 'ALTER TABLE `third_pay_order` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''支付创建时绑定的平台分佣关系主键'' AFTER `mch_pay_setting_json`', 'SELECT 1' ); PREPARE pay_relation_column_stmt FROM @pay_relation_column_sql; EXECUTE pay_relation_column_stmt; DEALLOCATE PREPARE pay_relation_column_stmt; + +SET @pay_relation_index_exists = ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'third_pay_order' + AND INDEX_NAME = 'idx_third_pay_order_fz_relation' +); +SET @pay_relation_index_sql = IF( + @pay_relation_index_exists = 0, + 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_fz_relation` (`tenant_id`, `fz_relation_id`)', + 'SELECT 1' +); +PREPARE pay_relation_index_stmt FROM @pay_relation_index_sql; +EXECUTE pay_relation_index_stmt; +DEALLOCATE PREPARE pay_relation_index_stmt; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardAuthDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardAuthDto.java index 2eac5ae6..4ea972c0 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardAuthDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardAuthDto.java @@ -1,12 +1,31 @@ package com.bsd.pjcloud.hard.api.dto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface; import com.bsd.pjcloud.hard.api.entity.HardAuthEntity; import lombok.Data; import java.time.LocalDate; +import java.util.List; @Data -public class HardAuthDto extends HardAuthEntity { +public class HardAuthDto extends HardAuthEntity implements BaseUserIdentityQueryInterface { + + /** 身份预筛选后用于授权记录查询的用户主键集合。 */ + private List userIds; + + /** UPMS 用户身份协议编码集合;为空时不限制身份。 */ + private List identityTypes; + + /** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */ + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */ + private String hasFace; private LocalDate[] createTimeList; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardBroadcastDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardBroadcastDto.java index 52af6d62..f15ab986 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardBroadcastDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardBroadcastDto.java @@ -1,12 +1,31 @@ package com.bsd.pjcloud.hard.api.dto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface; import com.bsd.pjcloud.hard.api.entity.HardBroadcastEntity; import lombok.Data; import java.time.LocalDate; +import java.util.List; @Data -public class HardBroadcastDto extends HardBroadcastEntity { +public class HardBroadcastDto extends HardBroadcastEntity implements BaseUserIdentityQueryInterface { + + /** 身份预筛选后用于广播记录查询的用户主键集合。 */ + private List userIds; + + /** UPMS 用户身份协议编码集合;为空时不限制身份。 */ + private List identityTypes; + + /** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */ + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */ + private String hasFace; private LocalDate[] createTimeList; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardDoorFlowDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardDoorFlowDto.java index 37ec0c8f..b2c852e4 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardDoorFlowDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardDoorFlowDto.java @@ -1,12 +1,31 @@ package com.bsd.pjcloud.hard.api.dto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface; import com.bsd.pjcloud.hard.api.entity.HardDoorFlowEntity; import lombok.Data; import java.time.LocalDate; +import java.util.List; @Data -public class HardDoorFlowDto extends HardDoorFlowEntity { +public class HardDoorFlowDto extends HardDoorFlowEntity implements BaseUserIdentityQueryInterface { + + /** 身份预筛选后用于开门流程查询的用户主键集合。 */ + private List userIds; + + /** UPMS 用户身份协议编码集合;为空时不限制身份。 */ + private List identityTypes; + + /** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */ + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */ + private String hasFace; private LocalDate[] createTimeList; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardInvoiceDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardInvoiceDto.java index 28d6ec52..e90e5bf4 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardInvoiceDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardInvoiceDto.java @@ -1,5 +1,8 @@ package com.bsd.pjcloud.hard.api.dto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface; import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity; import lombok.Data; @@ -7,12 +10,24 @@ import java.time.LocalDate; import java.util.List; @Data -public class HardInvoiceDto extends HardInvoiceEntity { +public class HardInvoiceDto extends HardInvoiceEntity implements BaseUserIdentityQueryInterface { private List deptIds; private List userIds; + /** UPMS 用户身份协议编码集合;为空时不限制身份。 */ + private List identityTypes; + + /** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */ + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */ + private String hasFace; + private List merchantIds; private String hardType; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java index 10450a42..828d157c 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java @@ -1,5 +1,8 @@ package com.bsd.pjcloud.hard.api.dto.order; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface; import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity; import lombok.Data; @@ -8,10 +11,22 @@ import java.time.LocalDateTime; import java.util.List; @Data -public class HardAfterSaleDto extends HardAfterSaleEntity { +public class HardAfterSaleDto extends HardAfterSaleEntity implements BaseUserIdentityQueryInterface { private List userIds; + /** UPMS 用户身份协议编码集合;为空时不限制身份。 */ + private List identityTypes; + + /** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */ + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */ + private String hasFace; + private LocalDate[] createTimeList; private List mchIds; 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 a25062a1..732377b4 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 @@ -1,6 +1,9 @@ package com.bsd.pjcloud.hard.api.dto.order; import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; import com.bsd.pjcloud.common.core.constant.enums.DateRangeTypeEnum; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import lombok.Data; @@ -9,8 +12,9 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +/** 硬件订单后台查询条件,身份字段用于按订单用户的身份资料筛选。 */ @Data -public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeInterface { +public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface { private DateRangeTypeEnum dateRangeType = DateRangeTypeEnum.YESTERDAY; @@ -131,4 +135,16 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn * `apptId`或者`appt_rule_id`不为null(筛选预约订单) */ Boolean apptIdOrApptRuleIdNotNull = false; + + /** 用户身份协议编码集合;为空表示不限制身份。 */ + private List identityTypes; + + /** 学生身份扩展筛选条件。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展筛选条件。 */ + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态:1 已录入、0 未录入、空值表示全部。 */ + private String hasFace; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/spu/HardSpuRepertoryDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/spu/HardSpuRepertoryDto.java index 94e3622f..5e62601a 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/spu/HardSpuRepertoryDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/spu/HardSpuRepertoryDto.java @@ -1,5 +1,8 @@ package com.bsd.pjcloud.hard.api.dto.spu; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface; import com.bsd.pjcloud.hard.api.entity.spu.HardSpuRepertoryEntity; import lombok.Data; @@ -7,7 +10,8 @@ import java.time.LocalDateTime; import java.util.List; @Data -public class HardSpuRepertoryDto extends HardSpuRepertoryEntity { +/** 硬件商品库存更新记录查询条件,支持按上架用户身份快速筛选。 */ +public class HardSpuRepertoryDto extends HardSpuRepertoryEntity implements BaseUserIdentityQueryInterface { private LocalDateTime[] createTimeList; @@ -15,4 +19,16 @@ public class HardSpuRepertoryDto extends HardSpuRepertoryEntity { private Long cabinetId; + /** UPMS 用户身份协议编码集合;为空表示不限制身份。 */ + private List identityTypes; + + /** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */ + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */ + private String hasFace; + } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardAuthVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardAuthVo.java index 4baf063e..30347ec5 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardAuthVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardAuthVo.java @@ -1,6 +1,9 @@ package com.bsd.pjcloud.hard.api.vo; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface; import com.bsd.pjcloud.hard.api.entity.HardAuthEntity; import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface; import lombok.Data; @@ -8,7 +11,7 @@ import lombok.Data; import java.util.List; @Data -public class HardAuthVo extends HardAuthEntity implements BaseHardInterface, BaseUserDeptInterface { +public class HardAuthVo extends HardAuthEntity implements BaseHardInterface, BaseUserDeptInterface, BaseUserIdentityInterface { /** * 设备相关 @@ -26,10 +29,22 @@ public class HardAuthVo extends HardAuthEntity implements BaseHardInterface, Bas private String userMobile; + /** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */ + private String phone; + private String userAvatar; private Long userDeptId; private String userDeptName; + /** 用户拥有的 UPMS 身份协议编码集合。 */ + private List identityTypes; + + /** 用户的学生身份扩展资料;无学生身份时为空。 */ + private SysUserStudentDto studentInfo; + + /** 用户的老师身份扩展资料;无老师身份时为空。 */ + private SysUserTeacherDto teacherInfo; + } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardBroadcastVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardBroadcastVo.java index 22967938..12d3a11e 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardBroadcastVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardBroadcastVo.java @@ -1,6 +1,9 @@ package com.bsd.pjcloud.hard.api.vo; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface; import com.bsd.pjcloud.hard.api.entity.HardBroadcastEntity; import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface; import lombok.Data; @@ -8,7 +11,7 @@ import lombok.Data; import java.util.List; @Data -public class HardBroadcastVo extends HardBroadcastEntity implements BaseHardInterface, BaseUserDeptInterface { +public class HardBroadcastVo extends HardBroadcastEntity implements BaseHardInterface, BaseUserDeptInterface, BaseUserIdentityInterface { /** * 设备相关 @@ -26,12 +29,24 @@ public class HardBroadcastVo extends HardBroadcastEntity implements BaseHardInte private String userMobile; + /** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */ + private String phone; + private String userAvatar; private Long userDeptId; private String userDeptName; + /** 用户拥有的 UPMS 身份协议编码集合。 */ + private List identityTypes; + + /** 用户的学生身份扩展资料;无学生身份时为空。 */ + private SysUserStudentDto studentInfo; + + /** 用户的老师身份扩展资料;无老师身份时为空。 */ + private SysUserTeacherDto teacherInfo; + /** * 订单相关 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardDoorFlowVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardDoorFlowVo.java index ec71266b..f0974aa8 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardDoorFlowVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardDoorFlowVo.java @@ -1,6 +1,9 @@ package com.bsd.pjcloud.hard.api.vo; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface; import com.bsd.pjcloud.hard.api.entity.HardAuthEntity; import com.bsd.pjcloud.hard.api.entity.HardDoorFlowEntity; import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface; @@ -9,7 +12,7 @@ import lombok.Data; import java.util.List; @Data -public class HardDoorFlowVo extends HardDoorFlowEntity implements BaseHardInterface, BaseUserDeptInterface { +public class HardDoorFlowVo extends HardDoorFlowEntity implements BaseHardInterface, BaseUserDeptInterface, BaseUserIdentityInterface { /** * 设备相关 @@ -27,12 +30,24 @@ public class HardDoorFlowVo extends HardDoorFlowEntity implements BaseHardInterf private String userMobile; + /** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */ + private String phone; + private String userAvatar; private Long userDeptId; private String userDeptName; + /** 用户拥有的 UPMS 身份协议编码集合。 */ + private List identityTypes; + + /** 用户的学生身份扩展资料;无学生身份时为空。 */ + private SysUserStudentDto studentInfo; + + /** 用户的老师身份扩展资料;无老师身份时为空。 */ + private SysUserTeacherDto teacherInfo; + private HardAuthEntity hardAuth; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardInvoiceVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardInvoiceVo.java index 0cd57feb..77a0a1d1 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardInvoiceVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardInvoiceVo.java @@ -1,7 +1,10 @@ package com.bsd.pjcloud.hard.api.vo; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface; -import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface; import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity; import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface; import com.bsd.pjcloud.hard.api.vo.base.BaseOrderInterface; @@ -9,9 +12,10 @@ import com.bsd.pjcloud.wallet.api.vo.base.BaseMchInterface; import lombok.Data; import java.time.LocalDateTime; +import java.util.List; @Data -public class HardInvoiceVo extends HardInvoiceEntity implements BaseDeptInterface, BaseMchInterface, BaseOrderInterface, BaseHardInterface, BaseUserInterface { +public class HardInvoiceVo extends HardInvoiceEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface, BaseMchInterface, BaseOrderInterface, BaseHardInterface { private Long historyId; @@ -72,6 +76,24 @@ public class HardInvoiceVo extends HardInvoiceEntity implements BaseDeptInterfac private String userMobile; + /** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */ + private String phone; + private String userAvatar; + /** UPMS 当前用户部门主键,用于公共用户信息组件的标准别名。 */ + private Long userDeptId; + + /** UPMS 当前用户部门名称,用于公共用户信息组件的标准别名。 */ + private String userDeptName; + + /** 用户拥有的 UPMS 身份协议编码集合。 */ + private List identityTypes; + + /** 用户的学生身份扩展资料;无学生身份时为空。 */ + private SysUserStudentDto studentInfo; + + /** 用户的老师身份扩展资料;无老师身份时为空。 */ + private SysUserTeacherDto teacherInfo; + } 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 49b3e4ad..e265efba 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 @@ -1,15 +1,19 @@ package com.bsd.pjcloud.hard.api.vo.order; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface; -import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface; import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity; import com.bsd.pjcloud.hard.api.vo.base.BaseOrderInterface; import lombok.Data; import java.time.LocalDateTime; +import java.util.List; @Data -public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserInterface, BaseDeptInterface, BaseOrderInterface { +public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface, BaseOrderInterface { /** * 自动退款任务状态;为空表示当前售后仍走人工审核,非空时前端不得展示审核或取消操作。 */ @@ -40,8 +44,26 @@ public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserInte private String userMobile; + /** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */ + private String phone; + private String userAvatar; + /** UPMS 当前用户部门主键,用于公共用户信息组件的标准别名。 */ + private Long userDeptId; + + /** UPMS 当前用户部门名称,用于公共用户信息组件的标准别名。 */ + private String userDeptName; + + /** 用户拥有的 UPMS 身份协议编码集合。 */ + private List identityTypes; + + /** 用户的学生身份扩展资料;无学生身份时为空。 */ + private SysUserStudentDto studentInfo; + + /** 用户的老师身份扩展资料;无老师身份时为空。 */ + private SysUserTeacherDto teacherInfo; + /** * 部门相关 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java index 96f057a1..843d866e 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java @@ -11,6 +11,11 @@ import lombok.Data; @Data public class HardCanteenProductApptExportRecord { + /** + * 导出聚合层级,固定为 SUMMARY、MERCHANT 或 PRODUCT;仅用于一次查询后拆分三级结果,不持久化。 + */ + private String recordLevel; + /** 业务发生日期,格式为 yyyy-MM-dd;线下取支付日期,预约取核销日期。 */ private String statisticsDate; 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 ffe257dd..afa0bf53 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 @@ -1,6 +1,9 @@ package com.bsd.pjcloud.hard.api.vo.order; import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface; import com.bsd.pjcloud.wallet.api.vo.base.BaseMchInterface; @@ -11,8 +14,9 @@ import lombok.Data; import java.time.LocalTime; import java.util.List; +/** 硬件订单管理端展示对象,包含公共用户主资料、部门及身份信息。 */ @Data -public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterface, BaseMchInterface, BaseHardInterface { +public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseMchInterface, BaseHardInterface { /** 统计明细来源编码;线下订单为 OFFLINE_PURCHASE,预约已核销订单为 RESERVATION_VERIFIED。 */ private String saleSource; @@ -28,6 +32,15 @@ public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterfac private String userAvatar; + /** 用户身份协议编码集合;没有身份时为空集合或空值。 */ + private List identityTypes; + + /** 学生身份扩展资料;没有学生身份时为空。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展资料;没有老师身份时为空。 */ + private SysUserTeacherDto teacherInfo; + /** * 部门相关 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/spu/HardSpuRepertoryVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/spu/HardSpuRepertoryVo.java index 052836be..d9157f4b 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/spu/HardSpuRepertoryVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/spu/HardSpuRepertoryVo.java @@ -1,5 +1,9 @@ package com.bsd.pjcloud.hard.api.vo.spu; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface; import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface; import com.bsd.pjcloud.hard.api.entity.spu.HardSpuRepertoryEntity; import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface; @@ -7,7 +11,8 @@ import com.bsd.pjcloud.wallet.api.vo.base.BaseMchInterface; import lombok.Data; @Data -public class HardSpuRepertoryVo extends HardSpuRepertoryEntity implements BaseMchInterface, BaseUserInterface, BaseHardInterface { +/** 硬件商品库存更新记录展示对象,包含上架用户主资料、部门和身份信息。 */ +public class HardSpuRepertoryVo extends HardSpuRepertoryEntity implements BaseMchInterface, BaseUserInterface, BaseUserDeptInterface, BaseUserIdentityInterface, BaseHardInterface { /** * 商品相关 @@ -45,6 +50,52 @@ public class HardSpuRepertoryVo extends HardSpuRepertoryEntity implements BaseMc private String userAvatar; + /** 用户标准手机号字段,供统一身份组件展示;与历史 userMobile 字段并存。 */ + private String phone; + + /** 用户当前所属部门主键,由 UPMS 用户资料回填。 */ + private Long userDeptId; + + /** 用户当前所属部门名称,由 UPMS 用户资料回填。 */ + private String userDeptName; + + /** 用户所属部门展示名称,供用户信息单元格读取。 */ + private String deptName; + + /** 用户拥有的 UPMS 身份协议编码集合。 */ + private java.util.List identityTypes; + + /** 用户的学生身份扩展资料;无学生身份时为空。 */ + private SysUserStudentDto studentInfo; + + /** 用户的老师身份扩展资料;无老师身份时为空。 */ + private SysUserTeacherDto teacherInfo; + + /** 获取 UPMS 返回的用户当前部门主键。 */ + @Override + public Long getUserDeptId() { + return userDeptId; + } + + /** 保存 UPMS 返回的用户当前部门主键。 */ + @Override + public void setUserDeptId(Long userDeptId) { + this.userDeptId = userDeptId; + } + + /** 获取 UPMS 返回的用户当前部门名称。 */ + @Override + public String getUserDeptName() { + return userDeptName; + } + + /** 保存 UPMS 返回的用户当前部门名称,并同步公共展示字段。 */ + @Override + public void setUserDeptName(String userDeptName) { + this.userDeptName = userDeptName; + this.deptName = userDeptName; + } + /** * 设备相关 */ 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 26048912..b89696d7 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 @@ -8,8 +8,6 @@ import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; import com.bsd.pjcloud.common.log.annotation.SysLog; -import com.bsd.pjcloud.common.security.service.PjcloudUser; -import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; import com.bsd.pjcloud.hard.api.dto.order.HardCanteenUserApptQueryDto; import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; @@ -18,11 +16,8 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptOrderVo; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptVo; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsVo; import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo; -import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo; -import com.bsd.pjcloud.hard.excel.HardCanteenProductApptExportMergeHandler; import com.bsd.pjcloud.hard.service.HardCanteenApptTotalService; import com.bsd.pjcloud.hard.service.HardCanteenApptTotalExportService; -import com.bsd.pjcloud.hard.service.HardCanteenTotalService; import com.bsd.pjcloud.wallet.api.dto.MchManageDto; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; @@ -55,9 +50,6 @@ import java.util.List; @SecurityRequirement(name = HttpHeaders.AUTHORIZATION) public class HardCanteenApptTotalController { - /** 无可见食堂时使用的不可命中商户主键,避免空集合被 SQL 当作“不筛选”而造成越权导出。 */ - private static final Long NO_VISIBLE_CANTEEN_ID = Long.MIN_VALUE; - /** * 食堂预约统计服务,负责菜品和用户维度的预约聚合。 */ @@ -68,11 +60,6 @@ public class HardCanteenApptTotalController { */ private final HardCanteenApptTotalExportService canteenApptTotalExportService; - /** - * 通用食堂统计服务,复用部门聚合以及商户、部门信息补全能力。 - */ - private final HardCanteenTotalService canteenTotalService; - /** * 商户远程服务,用于限定 App 食堂范围。 */ @@ -106,47 +93,6 @@ public class HardCanteenApptTotalController { /* ************************************* end app end ************************************* */ - /** - * 按预约使用日期统计食堂预约部门。 - * - * @param dto 预约统计筛选条件,其中 {@code apptUseDateTimeList} 表示预约使用日期范围 - * @return 按食堂聚合并包含部门、用餐时间段明细的预约统计结果 - */ - @Operation(summary = "食堂预约部门统计", description = "按预约使用日期统计食堂预约部门") - @ApiCryptoAes - @PostMapping("/list-by-dept") - public R> listByDept(@RequestBody HardOrderDto dto) { - // 预约统计必须以预约使用日期为唯一时间口径,避免调用方残留支付时间条件导致交叉筛选。 - dto.setPayTimeList(null); - dto.setOrderType(HardOrderTypeEnum.RESERVE.getType()); - dto.setTotalIsFindChildren(true); - dto.setApptIdOrApptRuleIdNotNull(true); - PjcloudUser user = SecurityUtils.getUser(); - return R.ok(canteenTotalService.listByDept(dto, user, null)); - } - - /** - * 分页查询后台食堂菜品预约统计。 - * - * @param page 分页参数 - * @param dto 预约订单筛选条件 - * @return 菜品预约统计分页 - */ - @Operation(summary = "食堂菜品预约统计", description = "食堂菜品预约统计") - @PostMapping("/proudct-reserv-total") - public R> productReservTotal(@ParameterObject Page page, @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()); - } - dto.setOrderType(HardOrderTypeEnum.RESERVE.getType()); - dto.setApptIdOrApptRuleIdNotNull(true); - return R.ok(canteenApptTotalService.productReservTotal(page, dto, null)); - } - /** * 分页查询用户在指定用餐日期范围内每天的预约情况。 * @@ -228,67 +174,4 @@ public class HardCanteenApptTotalController { return canteenApptTotalExportService.userReservationExport(query); } - /** - * 提交菜品预约统计异步导出任务。 - * - * @param query 菜品预约日期、餐段、食堂和部门筛选条件 - * @return 供前端轮询的初始任务进度 - */ - @Operation(summary = "菜品预约统计异步导出", description = "按日期分批生成菜品预约三级统计 Excel") - @SysLog("菜品预约统计异步导出") - @PreAuthorize("@pms.hasPermission('hard_canteenProductApptTotal_export')") - @PostMapping("/product-reserv-export-task") - public R 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 0b509485..1841b6cc 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 @@ -2,6 +2,7 @@ package com.bsd.pjcloud.hard.controller.order; import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; @@ -10,7 +11,8 @@ import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo; -import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenDeptTotalExcelVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo; +import com.bsd.pjcloud.hard.excel.HardCanteenProductApptExportMergeHandler; import com.bsd.pjcloud.hard.service.HardCanteenTotalService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -58,11 +60,25 @@ public class HardCanteenDeptTotalController { @Operation(summary = "食堂菜品部门统计导出", description = "按日期、食堂、来源、部门和餐段导出销售统计") @SysLog("食堂菜品部门统计导出") @PreAuthorize("@pms.hasPermission('hard_canteenDeptTotal_export')") - @ResponseExcel(name = "食堂菜品部门统计") + @ResponseExcel(name = "食堂菜品部门统计", inMemory = true, + writeHandler = HardCanteenProductApptExportMergeHandler.class) @PostMapping("/export-dept") - public List exportDept(@RequestBody HardOrderDto dto) { - PjcloudUser user = SecurityUtils.getUser(); - return canteenTotalService.exportDept(dto, user, null); + public List exportDept(@RequestBody HardOrderDto dto) { + return canteenTotalService.exportSales(dto, null); + } + + /** + * 提交食堂部门销售统计异步导出任务。 + * + * @param dto 来源统计日期、来源、食堂、餐段和部门筛选 + * @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址 + */ + @Operation(summary = "食堂菜品部门统计异步导出", description = "按自然日分批查询并流式生成部门销售统计 Excel") + @SysLog("食堂菜品部门统计异步导出") + @PreAuthorize("@pms.hasPermission('hard_canteenDeptTotal_export')") + @PostMapping("/export-dept-task") + public R submitDepartmentSalesExport(@RequestBody HardOrderDto dto) { + return R.ok(canteenTotalService.submitDepartmentSalesExport(dto), "食堂菜品部门统计导出任务已提交"); } } 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 d0ae0980..22ed964e 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 @@ -1,6 +1,7 @@ package com.bsd.pjcloud.hard.controller.order; import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; import cn.hutool.core.util.ObjectUtil; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; @@ -23,7 +24,8 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByCanteenVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByClassifyVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductLikeTopVo; -import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductTotalExcelVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo; +import com.bsd.pjcloud.hard.excel.HardCanteenProductApptExportMergeHandler; import com.bsd.pjcloud.hard.service.HardCanteenTotalService; import com.bsd.pjcloud.wallet.api.dto.MchManageDto; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; @@ -150,17 +152,28 @@ public class HardCanteenTotalController { */ @Operation(summary = "食堂菜品销售统计导出", description = "按日期、餐段和来源导出食堂菜品销售统计") @SysLog("食堂菜品销售统计导出") - @PreAuthorize("@pms.hasPermission('hard_canteenProductTotal_export')") - @ResponseExcel(name = "食堂菜品销售统计") + @PreAuthorize("@pms.hasPermission('hard_canteenProductTotal_export') or " + + "@pms.hasPermission('hard_canteenProductApptTotal_export')") + @ResponseExcel(name = "食堂菜品销售统计", inMemory = true, + writeHandler = HardCanteenProductApptExportMergeHandler.class) @PostMapping("/export-product") - public List 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); + public List exportProduct(@RequestBody HardOrderDto dto) { + return canteenTotalService.exportSales(dto, null); + } + + /** + * 提交食堂菜品销售统计异步导出任务。 + * + * @param dto 来源统计日期、来源、食堂、餐段和部门筛选 + * @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址 + */ + @Operation(summary = "食堂菜品销售统计异步导出", description = "按自然日分批查询并流式生成销售统计 Excel") + @SysLog("食堂菜品销售统计异步导出") + @PreAuthorize("@pms.hasPermission('hard_canteenProductTotal_export') or " + + "@pms.hasPermission('hard_canteenProductApptTotal_export')") + @PostMapping("/export-product-task") + public R submitProductSalesExport(@RequestBody HardOrderDto dto) { + return R.ok(canteenTotalService.submitProductSalesExport(dto), "食堂菜品销售统计导出任务已提交"); } /** diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java index fa9811af..7e2d5497 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java @@ -47,7 +47,7 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler /** 日期分组边框线型,参考模板使用中等线宽突出每天的数据边界。 */ private static final BorderStyle DATE_GROUP_BORDER_STYLE = BorderStyle.MEDIUM; - /** 日期分组边框颜色,按预约日期顺序交替使用蓝色和橙色。 */ + /** 日期分组边框颜色,按统计日期顺序交替使用蓝色和橙色。 */ private static final byte[][] DATE_GROUP_BORDER_COLORS = { {0x5B, (byte) 0x9B, (byte) 0xD5}, {(byte) 0xED, 0x7D, 0x31} @@ -75,13 +75,13 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler /** 最近写出的数据行号,用于工作簿结束时收尾最后一组。 */ private int lastDataRow = -1; - /** 当前预约日期分组序号,同一天的所有餐段共用一个序号和边框颜色。 */ + /** 当前统计日期分组序号,同一天的所有餐段和来源块共用一个序号和边框颜色。 */ private int dateGroupIndex = -1; - /** 当前预约日期的单元格值标识,用于判断新餐段是否仍属于同一天。 */ + /** 当前统计日期的单元格值标识,用于判断新餐段或来源块是否仍属于同一天。 */ private String currentDateKey; - /** 当前导出工作表;本处理器只注册到菜品预约导出的单个工作表。 */ + /** 当前导出工作表;本处理器只注册到菜品销售统计导出的单个工作表。 */ private Sheet currentSheet; /** @@ -203,14 +203,14 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler } /** - * 为当前预约日期设置整组外框:当天首个餐段首行加顶部边框,当天所有行加左右边框,末行加底部边框。 + * 为当前统计日期设置整组外框:当天首个来源块首行加顶部边框,当天所有行加左右边框,末行加底部边框。 * *

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

* * @param row 当前数据行 * @param holder 当前工作簿持有者,用于创建彩色边框样式 - * @param groupIndex 当前预约日期分组序号,未识别到日期时为负数 + * @param groupIndex 当前统计日期分组序号,未识别到日期时为负数 * @param topBorder 是否为当天首行并设置顶部边框 * @param bottomBorder 是否为当天末行并设置底部边框 */ @@ -284,10 +284,10 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler } /** - * 获取日期汇总单元格的稳定值标识;同一预约日期的不同餐段会得到相同标识。 + * 获取日期汇总单元格的稳定值标识;同一统计日期的不同餐段和来源块会得到相同标识。 * * @param row 当前日期餐段汇总行 - * @param columnIndex 预约日期所在列索引 + * @param columnIndex 统计日期所在列索引 * @return 用于判断日期是否变化的非空值标识 */ private String getCellValueKey(Row row, int columnIndex) { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardDoorFlowMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardDoorFlowMapper.java index 16a98893..8f918f78 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardDoorFlowMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardDoorFlowMapper.java @@ -20,6 +20,7 @@ public interface HardDoorFlowMapper extends PjcloudBaseMapper wrapper = new MPJLambdaQueryWrapper() .selectAll(HardDoorFlowEntity.class) .eq(ObjectUtil.isNotEmpty(dto.getUserId()), HardDoorFlowEntity::getUserId, dto.getUserId()) + .in(ObjectUtil.isNotEmpty(dto.getUserIds()), HardDoorFlowEntity::getUserId, dto.getUserIds()) .eq(ObjectUtil.isNotEmpty(dto.getBizType()), HardDoorFlowEntity::getBizType, dto.getBizType()) .eq(ObjectUtil.isNotEmpty(dto.getHardDoorId()), HardDoorFlowEntity::getHardDoorId, dto.getHardDoorId()) .eq(ObjectUtil.isNotEmpty(dto.getCabinetNo()), HardDoorFlowEntity::getCabinetNo, dto.getCabinetNo()) @@ -35,4 +36,4 @@ public interface HardDoorFlowMapper extends PjcloudBaseMapper Page scopePageList(Page page, @Param("dto") HardInvoiceDto dto, @Param("dataScope") DataScope dataScope); -} \ No newline at end of file +} 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 ef4e2303..9a1e5485 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 @@ -49,8 +49,8 @@ public interface HardCarMapper extends PjcloudBaseMapper { /** * 查询菜品多维统计行且不应用数据库分页。 * - *

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

+ *

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

* * @param dto 统计日期、来源、食堂、餐段和部门筛选 * @param dataScope 订单所属部门数据范围;为空时不附加范围条件 @@ -70,28 +70,15 @@ public interface HardCarMapper extends PjcloudBaseMapper { Page productTotalByApptUseDate(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope); /** - * 查询菜品销售三级导出的日期餐段汇总及来源拆分。 + * 一次查询菜品销售三级导出的汇总、商户部门和商品聚合行。 + * + *

SQL 只构造一次来源 CTE,再按记录层级分别聚合,避免三个查询重复扫描订单、购物车和退款数据。

* * @param dto 统计日期、来源、餐段、食堂和部门筛选条件 - * @return 每个日期餐段的跨来源合计及各来源统计,交易人数在当前层级独立去重 + * @param dataScope 订单所属部门数据范围;为空时不附加范围条件 + * @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); + List listSalesExportRows(@Param("dto") HardOrderDto dto, DataScope dataScope); Page proudctQuery(Page page, @Param("dto") HardCarDto dto); 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 index 53172794..2ec1b525 100644 --- 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 @@ -2,8 +2,6 @@ package com.bsd.pjcloud.hard.service; import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; import com.bsd.pjcloud.hard.api.dto.order.HardCanteenUserApptQueryDto; -import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; -import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo; import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo; import java.util.List; @@ -31,20 +29,4 @@ public interface HardCanteenApptTotalExportService { * @return 用户预约导出行 */ List 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/HardCanteenTotalService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java index 7502b74e..ab70e8e0 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 @@ -2,6 +2,7 @@ package com.bsd.pjcloud.hard.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.hard.api.dto.order.HardCarDto; @@ -78,4 +79,20 @@ public interface HardCanteenTotalService { * @return 日期餐段、商户部门、商品三级排列的导出行 */ List exportSales(HardOrderDto dto, DataScope dataScope); + + /** + * 提交食堂菜品销售统计异步导出任务。 + * + * @param dto 统计日期、来源、餐段、食堂和部门筛选条件 + * @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址 + */ + AsyncTaskProgressVO submitProductSalesExport(HardOrderDto dto); + + /** + * 提交食堂部门销售统计异步导出任务。 + * + * @param dto 统计日期、来源、餐段、食堂和部门筛选条件 + * @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址 + */ + AsyncTaskProgressVO submitDepartmentSalesExport(HardOrderDto dto); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardAuthServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardAuthServiceImpl.java index ee9821de..c0fd9052 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardAuthServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardAuthServiceImpl.java @@ -34,6 +34,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardAppCreateThirdDoorOrderVo; import com.bsd.pjcloud.hard.mapper.HardAuthMapper; import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; import com.bsd.pjcloud.hard.service.*; +import com.bsd.pjcloud.hard.support.HardUserIdentitySupport; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -62,8 +63,12 @@ public class HardAuthServiceImpl extends ServiceImpl scopePageJoin(Page page, HardAuthDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); Page pageList = baseMapper.scopePageJoin(page, dto, dataScope); disposeList(pageList.getRecords(), dto); return pageList; @@ -237,14 +242,18 @@ public class HardAuthServiceImpl extends ServiceImpl userIds = list.stream().map(HardAuthVo::getUserId).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - R> userList = remoteUserService.getUserDeptByIds(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserDeptList(list, CollectionUtils.convertMap(userList.getData(), UserVO::getUserId)); - } - } + userIdentitySupport.fillUserInfo(list, true); + } + + /** 将授权用户身份条件预筛选为授权记录可直接使用的用户主键集合。 */ + private void prepareIdentityQuery(HardAuthDto dto) { + if (!userIdentitySupport.hasIdentityQuery(dto)) return; + dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds())); + dto.setUserId(null); + dto.setIdentityTypes(null); + dto.setStudentInfo(null); + dto.setTeacherInfo(null); + dto.setHasFace(null); } /** @@ -268,4 +277,4 @@ public class HardAuthServiceImpl extends ServiceImpl scopePageJoin(Page page, HardBroadcastDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); Page pageList = baseMapper.scopePageJoin(page, dto, dataScope); disposeList(pageList.getRecords(), dto); return pageList; @@ -310,14 +315,18 @@ public class HardBroadcastServiceImpl extends ServiceImpl userIds = list.stream().map(HardBroadcastVo::getUserId).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - R> userList = remoteUserService.getUserDeptByIds(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserDeptList(list, CollectionUtils.convertMap(userList.getData(), UserVO::getUserId)); - } - } + userIdentitySupport.fillUserInfo(list, true); + } + + /** 将广播预定用户身份条件预筛选为广播记录可直接使用的用户主键集合。 */ + private void prepareIdentityQuery(HardBroadcastDto dto) { + if (!userIdentitySupport.hasIdentityQuery(dto)) return; + dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds())); + dto.setUserId(null); + dto.setIdentityTypes(null); + dto.setStudentInfo(null); + dto.setTeacherInfo(null); + dto.setHasFace(null); } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardDoorFlowServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardDoorFlowServiceImpl.java index eb2a746f..301eeb9b 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardDoorFlowServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardDoorFlowServiceImpl.java @@ -41,6 +41,7 @@ import com.bsd.pjcloud.hard.mapper.HardDoorFlowMapper; import com.bsd.pjcloud.hard.mapper.HardThirdMiOrderMapper; import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; import com.bsd.pjcloud.hard.service.*; +import com.bsd.pjcloud.hard.support.HardUserIdentitySupport; import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdDoorApiService; import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdKsService; import com.bsd.pjcloud.thirdparty.dto.door.*; @@ -88,11 +89,15 @@ public class HardDoorFlowServiceImpl extends ServiceImpl scopePageJoin(Page page, HardDoorFlowDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); Page pageList = baseMapper.scopePageJoin(page, dto, dataScope); disposeList(pageList.getRecords(), dto); return pageList; @@ -427,14 +432,18 @@ public class HardDoorFlowServiceImpl extends ServiceImpl userIds = list.stream().map(HardDoorFlowVo::getUserId).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - R> userList = remoteUserService.getUserDeptByIds(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserDeptList(list, CollectionUtils.convertMap(userList.getData(), UserVO::getUserId)); - } - } + userIdentitySupport.fillUserInfo(list, true); + } + + /** 将开门流程用户身份条件预筛选为流程记录可直接使用的用户主键集合。 */ + private void prepareIdentityQuery(HardDoorFlowDto dto) { + if (!userIdentitySupport.hasIdentityQuery(dto)) return; + dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds())); + dto.setUserId(null); + dto.setIdentityTypes(null); + dto.setStudentInfo(null); + dto.setTeacherInfo(null); + dto.setHasFace(null); } private void disposeList(HardDoorFlowVo detail) { @@ -458,4 +467,4 @@ public class HardDoorFlowServiceImpl extends ServiceImpl scopeJoinPageList(Page page, HardInvoiceDto dto) { + prepareIdentityQuery(dto); Page list = baseMapper.scopePageList(page, dto, DataScope.of()); if (ObjectUtil.isNotEmpty(list.getRecords())) { // 部门 @@ -102,14 +107,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl userIds = list.getRecords().stream().map(HardInvoiceVo::getOrderUserId).filter(ObjectUtil::isNotEmpty).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - R> userList = userService.getUserById(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserList(list.getRecords(), CollectionUtils.convertMap(userList.getData(), SysUser::getUserId)); - } - } + userIdentitySupport.fillUserInfo(list.getRecords(), true); // 商户 List mchIds = list.getRecords().stream().map(HardInvoiceVo::getMerchantId).distinct().toList(); if (ObjectUtil.isNotEmpty(mchIds)) { @@ -122,6 +120,20 @@ public class HardInvoiceServiceImpl extends ServiceImpl pageJoin(Page page, HardAfterSaleDto dto) { + prepareIdentityQuery(dto); Page list = baseMapper.pageJoin(page, dto); if (ObjectUtil.isNotEmpty(list.getRecords())) { - // 用户 - List userIds = list.getRecords().stream().map(HardAfterSaleVo::getUserId).filter(ObjectUtil::isNotEmpty).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - R> userList = userService.getUserById(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserList(list.getRecords(), CollectionUtils.convertMap(userList.getData(), SysUser::getUserId)); - } - } + userIdentitySupport.fillUserInfo(list.getRecords(), true); // 部门 List deptIds = list.getRecords().stream().map(HardAfterSaleVo::getDeptId).filter(ObjectUtil::isNotEmpty).distinct().toList(); if (ObjectUtil.isNotEmpty(deptIds)) { @@ -151,16 +149,10 @@ public class HardAfterSaleServiceImpl extends ServiceImpl scopePageList(Page page, HardAfterSaleDto dto) { + prepareIdentityQuery(dto); Page list = baseMapper.scopePageJoinList(page, dto, null); if (ObjectUtil.isNotEmpty(list.getRecords())) { - // 用户 - List userIds = list.getRecords().stream().map(HardAfterSaleVo::getUserId).filter(ObjectUtil::isNotEmpty).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - R> userList = userService.getUserById(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserList(list.getRecords(), CollectionUtils.convertMap(userList.getData(), SysUser::getUserId)); - } - } + userIdentitySupport.fillUserInfo(list.getRecords(), true); // 部门 List deptIds = list.getRecords().stream().map(HardAfterSaleVo::getDeptId).filter(ObjectUtil::isNotEmpty).distinct().toList(); if (ObjectUtil.isNotEmpty(deptIds)) { @@ -174,6 +166,21 @@ public class HardAfterSaleServiceImpl extends ServiceImpl菜品导出以预约使用日期为批次边界,每完成一个日期就推进一次 Redis 进度;Excel 数据始终通过 - * Easy Excel 追加写入,避免把整个日期范围的商品明细一次性保留在内存中。

+ *

按权限用户分批查询每日预约并流式写入 Excel,避免长日期范围和大量用户一次性占用过多内存。

*/ @Slf4j @Validated @@ -131,33 +120,9 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot private static final DateTimeFormatter EXCEL_DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - /** 菜品预约导出进度的 Redis Key 命名空间。 */ - private static final String PRODUCT_EXPORT_REDIS_KEY = "hard:canteen-product-appt:export"; - - /** 菜品预约导出任务名称。 */ - private static final String PRODUCT_EXPORT_TASK_NAME = "菜品预约统计导出"; - - /** 异步任务创建时的占位总数,真实日期数量在后台查询后更新。 */ - private static final int INITIAL_PROGRESS_TOTAL = 1; - - /** 菜品预约导出工作表名称。 */ - private static final String PRODUCT_EXPORT_SHEET_NAME = "菜品预约统计"; - - /** 文件中心存储目录。 */ - private static final String EXPORT_FILE_DIR = "hard-export"; - - /** 文件中心业务类型编码。 */ - private static final String PRODUCT_EXPORT_FILE_TYPE = "HARD_CANTEEN_PRODUCT_APPT_EXPORT"; - - /** Excel 文件 MIME 类型。 */ - private static final String EXCEL_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; - /** 用餐时间段字典类型。 */ private static final String MEAL_SEGMENT_DICT_TYPE = "daily_menu"; - /** Excel 文件名时间格式。 */ - private static final DateTimeFormatter FILE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); - /** 用户预约查询允许的最大自然月跨度,开始和结束日期均计入查询范围。 */ private static final long MAX_QUERY_MONTHS = 2L; @@ -206,9 +171,6 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot /** 菜品预约聚合 Mapper。 */ private final HardCarMapper hardCarMapper; - /** 每日菜谱远程服务,用于展开套餐组成。 */ - private final RemoteDailyMenuService remoteDailyMenuService; - /** 部门远程服务,用于补齐预约人所属部门名称。 */ private final RemoteDeptService remoteDeptService; @@ -1303,459 +1265,4 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot } } - /** - * 提交菜品预约异步导出任务。 - * - * @param query 菜品预约统计筛选条件 - * @return 初始异步任务进度 - */ - @Override - public AsyncTaskProgressVO submitProductReservationExport(HardOrderDto query) { - HardOrderDto querySnapshot = copyProductQuery(query); - return asyncTaskExecutorService.submitWithResult(PRODUCT_EXPORT_REDIS_KEY, PRODUCT_EXPORT_TASK_NAME, - INITIAL_PROGRESS_TOTAL, reporter -> exportProducts(querySnapshot, reporter)); - } - - /** - * 同步查询菜品预约自定义 Excel 行。 - * - * @param query 菜品预约统计筛选条件 - * @return 三级层级导出行 - */ - @Override - public List 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/HardCanteenTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java index b8656b81..43bc3287 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 @@ -4,12 +4,16 @@ 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.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.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.admin.api.feign.RemoteFileService; import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenCategoryService; import com.bsd.pjcloud.canteen.api.feign.RemoteDailyMenuService; import com.bsd.pjcloud.canteen.entity.CanteenCategoryEntity; @@ -18,7 +22,13 @@ 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.core.util.R; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.hard.api.constant.order.HardCanteenStatisticsLevelEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; @@ -36,6 +46,7 @@ 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.excel.HardCanteenProductApptExportMergeHandler; import com.bsd.pjcloud.hard.mapper.order.HardCarMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.HardCanteenTotalService; @@ -44,14 +55,20 @@ 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.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.time.temporal.TemporalAdjusters; import java.util.ArrayList; import java.util.Comparator; @@ -83,6 +100,60 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { /** 当前用户没有可见食堂时使用的不可命中主键,防止空集合被解释为不限制商户。 */ private static final Long NO_VISIBLE_CANTEEN_ID = Long.MIN_VALUE; + /** 菜品统计异步导出的 Redis 业务命名空间。 */ + private static final String PRODUCT_SALES_EXPORT_REDIS_KEY = "hard:canteen-sales:export:product"; + + /** 部门统计异步导出的 Redis 业务命名空间。 */ + private static final String DEPARTMENT_SALES_EXPORT_REDIS_KEY = "hard:canteen-sales:export:department"; + + /** 菜品统计异步导出任务展示名称。 */ + private static final String PRODUCT_SALES_EXPORT_TASK_NAME = "食堂菜品销售统计导出"; + + /** 部门统计异步导出任务展示名称。 */ + private static final String DEPARTMENT_SALES_EXPORT_TASK_NAME = "食堂菜品部门统计导出"; + + /** 异步导出提交时使用的占位总数,后台开始后会替换为实际日期批次数。 */ + private static final int SALES_EXPORT_INITIAL_TOTAL = 1; + + /** 流式写完日期批次后的工作簿收尾和文件上传进度步数。 */ + private static final int SALES_EXPORT_EXTRA_PROGRESS_STEPS = 2; + + /** 三级统计 Excel 的双层表头行数。 */ + private static final int SALES_EXPORT_HEADER_ROW_COUNT = 2; + + /** 单个三级统计工作表允许写入的最大业务行数。 */ + private static final int MAX_SALES_EXPORT_ROWS = + BatchExcelExportTemplate.calculateMaxDetailRowCount(SALES_EXPORT_HEADER_ROW_COUNT, 0); + + /** 统一销售统计异步导出的工作表名称。 */ + private static final String SALES_EXPORT_SHEET_NAME = "食堂销售统计"; + + /** 统一销售统计异步导出的文件中心目录。 */ + private static final String SALES_EXPORT_FILE_DIR = "hard-export"; + + /** 菜品统计导出文件的文件中心业务类型。 */ + private static final String PRODUCT_SALES_EXPORT_FILE_TYPE = "HARD_CANTEEN_PRODUCT_SALES_EXPORT"; + + /** 部门统计导出文件的文件中心业务类型。 */ + private static final String DEPARTMENT_SALES_EXPORT_FILE_TYPE = "HARD_CANTEEN_DEPARTMENT_SALES_EXPORT"; + + /** 导出聚合记录中的日期餐段汇总层级编码,必须与 Mapper SQL 返回值一致。 */ + private static final String SALES_EXPORT_RECORD_LEVEL_SUMMARY = "SUMMARY"; + + /** 导出聚合记录中的商户部门层级编码,必须与 Mapper SQL 返回值一致。 */ + private static final String SALES_EXPORT_RECORD_LEVEL_MERCHANT = "MERCHANT"; + + /** 导出聚合记录中的商品层级编码,必须与 Mapper SQL 返回值一致。 */ + private static final String SALES_EXPORT_RECORD_LEVEL_PRODUCT = "PRODUCT"; + + /** XLSX 文件的标准 MIME 类型。 */ + private static final String SALES_EXPORT_EXCEL_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + + /** 异步导出文件名使用的时间格式,保证同一用户连续导出时文件名可区分。 */ + private static final DateTimeFormatter SALES_EXPORT_FILE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + private final HardOrderMapper hardOrderMapper; private final HardCarMapper hardCarMapper; @@ -97,6 +168,12 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { /** 餐段字典远程服务,仅在生成统计导出文案时读取 daily_menu。 */ private final RemoteDictService remoteDictService; + /** 文件中心远程服务,用于保存异步导出完成后的 XLSX 文件。 */ + private final RemoteFileService remoteFileService; + + /** 通用异步任务执行器,用于恢复租户和用户上下文并持久化导出进度。 */ + private final AsyncTaskExecutorService asyncTaskExecutorService; + /** * 菜品销量按照分类查询 */ @@ -381,46 +458,31 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { ? 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 = 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()); - 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); - } - - 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())); + if (ObjectUtil.isNotEmpty(list) && Boolean.TRUE.equals(dto.getTotalIsFindChildren())) { + if (sourceStatistics) { + populateDepartmentStatisticsChildren(dto, list, includeSourceRows); + } else { + // 旧接口仍保留原有并发查询方式,避免无统计日期的历史调用发生结构变化。 + int threadCount = Math.min(list.size(), 10); + 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); + for (HardCanteenProductTotalVo child : deptList) { + if (ObjectUtil.isNotEmpty(v.getMchId())) child.setMchId(v.getMchId()); } - } - }, executorService)) - .toList(); - CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); - } finally { - if (executorService != null) executorService.shutdown(); + v.setDeptCount(deptList.stream().map(HardCanteenProductTotalVo::getUserDeptId) + .distinct().toList().size()); + v.setChildren(deptList); + }, executorService)) + .toList(); + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + } finally { + if (executorService != null) executorService.shutdown(); + } } } @@ -490,6 +552,37 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { .toList(); } + /** + * 一次批量查询全部日期和食堂的部门餐段行,再按主表维度装配展开数据。 + * + *

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

+ * + * @param dto 已补全可见食堂、来源和统计日期的查询条件 + * @param mainRows 日期食堂合计主行,每行可能挂载来源拆分 + * @param includeSourceRows 是否为合计行挂载来源拆分;单来源标签页为 false + */ + private void populateDepartmentStatisticsChildren(HardOrderDto dto, + List mainRows, + boolean includeSourceRows) { + List allChildRows = hardCarMapper.listByHourTypeStats(dto); + Map> childRowsByMainScope = ObjectUtil.isEmpty(allChildRows) + ? Map.of() + : allChildRows.stream().collect(Collectors.groupingBy(this::departmentMainScopeKey, + LinkedHashMap::new, Collectors.toList())); + for (HardCanteenProductTotalVo mainRow : mainRows) { + List deptList = assembleDepartmentChildRows( + childRowsByMainScope.getOrDefault(departmentMainScopeKey(mainRow), List.of()), + mainRow.getMchId(), includeSourceRows); + mainRow.setChildren(deptList); + if (includeSourceRows) { + for (HardCanteenProductTotalVo sourceRow : mainRow.getSourceRows()) { + sourceRow.setChildren(departmentSourceChildren(deptList, sourceRow.getSaleSource())); + } + } + } + } + /** * 从餐段合计行中提取指定来源的部门餐段行,供主表来源行展开时复用同一批查询结果。 * @@ -580,72 +673,530 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { } /** - * 导出菜品统计,直接复用来源统计 SQL,确保导出与主表的业务发生时间口径一致。 + * 导出日期餐段、商户部门和商品三级销售统计。 * - * @param dto 来源统计请求 - * @param dataScope 部门数据权限 - * @return Excel 导出行 + *

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

+ * + * @param dto 统计日期、来源、餐段、食堂和部门筛选条件 + * @param dataScope 数据权限上下文,继续透传给 Mapper 拦截器 + * @return 按日期餐段、来源口径、商户部门和商品顺序排列的 Excel 行 */ @Override - public List exportProduct(HardOrderDto dto, DataScope dataScope) { - if (!isSourceStatistics(dto)) { - throw new IllegalArgumentException("导出必须选择统计日期范围"); + public List exportSales(HardOrderDto dto, DataScope dataScope) { + HardOrderDto exportQuery = prepareSalesExportQuery(dto); + List rows = new ArrayList<>(); + for (LocalDate date : getExportDates(exportQuery)) { + HardOrderDto dateQuery = createExportDateQuery(exportQuery, date); + rows.addAll(buildSalesExportRows(dateQuery, date, dataScope)); } - 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(); + return rows; } /** - * 导出部门统计,复用部门主表及子行装配结果后扁平化为 Excel 行。 + * 提交菜品统计异步分批导出任务。 * - * @param dto 来源统计请求 - * @param user 当前登录用户 - * @param dataScope 部门数据权限 - * @return Excel 导出行 + * @param dto 页面当前的统计日期、来源、餐段、食堂和部门筛选条件 + * @return 可供页面轮询的初始任务进度 */ @Override - public List exportDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope) { + public AsyncTaskProgressVO submitProductSalesExport(HardOrderDto dto) { + return submitSalesExport(dto, PRODUCT_SALES_EXPORT_REDIS_KEY, PRODUCT_SALES_EXPORT_TASK_NAME, + "食堂菜品销售统计", PRODUCT_SALES_EXPORT_FILE_TYPE); + } + + /** + * 提交部门统计异步分批导出任务。 + * + * @param dto 页面当前的统计日期、来源、餐段、食堂和部门筛选条件 + * @return 可供页面轮询的初始任务进度 + */ + @Override + public AsyncTaskProgressVO submitDepartmentSalesExport(HardOrderDto dto) { + return submitSalesExport(dto, DEPARTMENT_SALES_EXPORT_REDIS_KEY, DEPARTMENT_SALES_EXPORT_TASK_NAME, + "食堂菜品部门统计", DEPARTMENT_SALES_EXPORT_FILE_TYPE); + } + + /** + * 冻结请求快照和当前用户可见食堂后提交统一销售统计导出任务。 + * + *

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

+ * + * @param dto 页面提交的销售统计筛选条件 + * @param redisKey 当前导出入口独立使用的 Redis 业务命名空间 + * @param taskName 进度弹窗展示的任务名称 + * @param fileNamePrefix 下载文件名称前缀 + * @param fileType 文件中心保存的业务类型编码 + * @return 已创建的初始任务进度 + */ + private AsyncTaskProgressVO submitSalesExport(HardOrderDto dto, String redisKey, String taskName, + String fileNamePrefix, String fileType) { 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); + HardOrderDto querySnapshot = prepareSalesExportQuery(copyExportQuery(dto)); + return asyncTaskExecutorService.submitWithResult(redisKey, taskName, SALES_EXPORT_INITIAL_TOTAL, + reporter -> exportSalesToFile(querySnapshot, fileNamePrefix, fileType, reporter)); + } + + /** + * 校验并复制导出查询,同时把当前用户可见食堂范围写入独立快照。 + * + * @param source 控制器接收的原始筛选条件 + * @return 已规范化来源并冻结食堂权限的独立查询快照;传入对象继续保留同步接口原有的条件补全行为 + */ + private HardOrderDto prepareSalesExportQuery(HardOrderDto source) { + if (!isSourceStatistics(source)) { + throw new IllegalArgumentException("导出必须选择统计日期范围"); + } + normalizeSaleSources(source); + applyExportMerchantScope(source); + return copyExportQuery(source); + } + + /** + * 在异步线程中按自然日查询并流式写入三级统计 Excel,完成后上传文件中心。 + * + *

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

+ * + * @param query 已冻结权限范围的销售统计查询 + * @param fileNamePrefix 下载文件名称前缀 + * @param fileType 文件中心业务类型编码 + * @param reporter 当前异步任务进度报告器 + * @return 文件中心下载地址和业务文件名 + */ + private AsyncTaskResult exportSalesToFile(HardOrderDto query, String fileNamePrefix, String fileType, + AsyncTaskProgressReporter reporter) { + List dates = getExportDates(query); + reporter.updateTotal(dates.size() + SALES_EXPORT_EXTRA_PROGRESS_STEPS, + "已拆分为 " + dates.size() + " 个日期批次,正在生成统计文件"); + String fileName = fileNamePrefix + "_" + + LocalDateTime.now().format(SALES_EXPORT_FILE_TIME_FORMATTER) + ".xlsx"; + Path outputPath = null; + try { + outputPath = Files.createTempFile("canteen-sales-export-", ".xlsx"); + long exportedRows = writeSalesExportWorkbook(outputPath, query, dates, reporter); + reporter.advance("统计文件已完成写入,共 " + exportedRows + " 行,正在上传文件中心"); + String fileUrl = uploadSalesExportWorkbook(outputPath, fileName, fileType); + reporter.advance("统计文件已上传,共 " + exportedRows + " 行"); + return AsyncTaskResult.file(fileUrl, fileName); + } catch (IllegalArgumentException | IllegalStateException exception) { + throw exception; + } catch (Exception exception) { + log.error("|{}|异步导出生成或上传失败,fileName:{}", TAG, fileName, exception); + throw new IllegalStateException("生成食堂销售统计导出文件失败", exception); + } finally { + deleteTemporarySalesExportFile(outputPath); + } + } + + /** + * 按自然日批次查询三级统计行并写入同一个流式工作簿。 + * + * @param outputPath 上传前使用的临时 XLSX 文件 + * @param query 已冻结的完整筛选条件 + * @param dates 从开始日期到结束日期的有序自然日列表 + * @param reporter 当前任务进度报告器 + * @return 实际写入的业务行总数 + */ + private long writeSalesExportWorkbook(Path outputPath, HardOrderDto query, List dates, + AsyncTaskProgressReporter reporter) { + long exportedRows = 0L; + try (ExcelWriter excelWriter = EasyExcel.write(outputPath.toFile(), HardCanteenProductApptExcelVo.class) + .registerWriteHandler(new HardCanteenProductApptExportMergeHandler()).build()) { + WriteSheet writeSheet = EasyExcel.writerSheet(SALES_EXPORT_SHEET_NAME).build(); + for (int index = 0; index < dates.size(); index++) { + LocalDate date = dates.get(index); + HardOrderDto dateQuery = createExportDateQuery(query, date); + List dateRows = buildSalesExportRows(dateQuery, date, null); + long nextExportedRows = exportedRows + dateRows.size(); + if (nextExportedRows > MAX_SALES_EXPORT_ROWS) { + throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 " + + MAX_SALES_EXPORT_ROWS + " 行,请缩小统计日期或筛选范围后重试"); + } + if (!dateRows.isEmpty()) { + excelWriter.write(dateRows, writeSheet); + } + exportedRows = nextExportedRows; + reporter.advance("已处理 " + (index + 1) + " / " + dates.size() + " 个日期批次,已写入 " + + exportedRows + " 行"); + } + } + if (exportedRows == 0L) { + throw new IllegalStateException("当前筛选条件没有可导出的食堂销售统计数据"); + } + return exportedRows; + } + + /** + * 创建单个自然日的查询副本,并保留用户在首尾日期选择的具体时间边界。 + * + * @param query 已冻结的完整日期范围查询 + * @param date 当前待处理的自然日 + * @return 日期范围已收敛到当前自然日的独立查询 + */ + private HardOrderDto createExportDateQuery(HardOrderDto query, LocalDate date) { + HardOrderDto dateQuery = copyExportQuery(query); + LocalDateTime rangeStart = query.getStatisticsDateTimeList()[0]; + LocalDateTime rangeEnd = query.getStatisticsDateTimeList()[1]; + LocalDateTime dayStart = date.atStartOfDay(); + LocalDateTime dayEnd = date.atTime(23, 59, 59); + dateQuery.setStatisticsDateTimeList(new LocalDateTime[]{ + rangeStart.isAfter(dayStart) ? rangeStart : dayStart, + rangeEnd.isBefore(dayEnd) ? rangeEnd : dayEnd + }); + return dateQuery; + } + + /** + * 将已关闭的完整 XLSX 临时文件上传到统一文件中心。 + * + * @param outputPath 已完成流式写入的临时文件 + * @param fileName 用户下载时使用的 UTF-8 文件名 + * @param fileType 文件中心业务类型编码 + * @return 文件中心返回的下载地址 + * @throws Exception 文件读取或远程上传失败时抛出 + */ + private String uploadSalesExportWorkbook(Path outputPath, String fileName, String fileType) throws Exception { + MultipartFile exportFile = new MockMultipartFile("file", encodeSalesExportFileName(fileName), + SALES_EXPORT_EXCEL_CONTENT_TYPE, Files.readAllBytes(outputPath)); + R uploadResult = remoteFileService.innerUpload(exportFile, SALES_EXPORT_FILE_DIR, null, fileType); + if (uploadResult == null || !uploadResult.isOk() + || !(uploadResult.getData() instanceof Map resultData)) { + throw new IllegalStateException("文件中心未返回有效的食堂销售统计导出结果"); + } + Object resultUrl = resultData.get("url"); + if (resultUrl == null || StrUtil.isBlank(resultUrl.toString())) { + throw new IllegalStateException("文件中心未返回食堂销售统计导出下载地址"); + } + return resultUrl.toString(); + } + + /** + * 按文件服务现有编码约定转换中文原始文件名。 + * + * @param fileName 用户看到的 UTF-8 文件名 + * @return 文件中心上传接口使用的原始文件名 + */ + private String encodeSalesExportFileName(String fileName) { + return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); + } + + /** + * 清理异步导出的临时文件,清理失败只记录告警而不覆盖任务的原始结果。 + * + * @param outputPath 待删除的临时文件路径,允许为空 + */ + private void deleteTemporarySalesExportFile(Path outputPath) { + if (outputPath == null) { + return; + } + try { + Files.deleteIfExists(outputPath); + } catch (Exception exception) { + log.warn("|{}|清理销售统计导出临时文件失败,path:{}", TAG, outputPath, exception); + } + } + + /** + * 将当前用户可见食堂范围冻结到导出请求中。 + * + *

调用方传入的食堂也必须与可见范围取交集;无可见食堂时使用不可命中主键,避免空集合被 SQL + * 解释为“不限制食堂”。

+ * + * @param dto 待补充食堂权限范围的导出请求 + */ + private void applyExportMerchantScope(HardOrderDto dto) { + MchManageDto scopeQuery = new MchManageDto(); + scopeQuery.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType())); + List visibleMerchants = remoteMchService.scopeList(scopeQuery).getData(); + List visibleIds = ObjectUtil.isEmpty(visibleMerchants) ? List.of() + : visibleMerchants.stream().map(MchManageVo::getId).filter(Objects::nonNull).distinct().toList(); + if (visibleIds.isEmpty()) { + dto.setMchIds(List.of(NO_VISIBLE_CANTEEN_ID)); + return; + } + if (dto.getMerchantId() != null && !visibleIds.contains(dto.getMerchantId())) { + dto.setMchIds(List.of(NO_VISIBLE_CANTEEN_ID)); + return; + } + if (ObjectUtil.isEmpty(dto.getMchIds())) { + dto.setMchIds(visibleIds); + return; + } + List allowedIds = dto.getMchIds().stream().filter(visibleIds::contains).distinct().toList(); + dto.setMchIds(allowedIds.isEmpty() ? List.of(NO_VISIBLE_CANTEEN_ID) : allowedIds); + } + + /** + * 查询一个自然日的三层销售数据并转换为 Excel 行。 + * + * @param query 已收敛到单个自然日的查询条件 + * @param fixedDate 当前批次业务日期 + * @param dataScope 数据权限上下文 + * @return 当前日期下合计和各来源的日期餐段、商户部门、商品明细及商品统计行 + */ + private List buildSalesExportRows(HardOrderDto query, LocalDate fixedDate, + DataScope dataScope) { + List exportRecords = hardCarMapper.listSalesExportRows(query, dataScope); + List summaries = filterSalesExportRecords( + exportRecords, SALES_EXPORT_RECORD_LEVEL_SUMMARY); + if (ObjectUtil.isEmpty(summaries)) { + return List.of(); + } + List merchants = filterSalesExportRecords( + exportRecords, SALES_EXPORT_RECORD_LEVEL_MERCHANT); + List products = filterSalesExportRecords( + exportRecords, SALES_EXPORT_RECORD_LEVEL_PRODUCT); + Map merchantNames = fetchExportMerchantNames(merchants); + Map departmentNames = fetchExportDepartmentNames(merchants); + Map hourNames = getHourTypeNames(); + Map menuMap = fetchExportDailyMenus(products, fixedDate); + Map> merchantGroups = merchants.stream() + .collect(Collectors.groupingBy(this::exportSummaryGroupKey, LinkedHashMap::new, Collectors.toList())); + Map> productGroups = products.stream() + .collect(Collectors.groupingBy(this::exportGroupKey, LinkedHashMap::new, Collectors.toList())); + List rows = new ArrayList<>(); + for (HardCanteenProductApptExportRecord summary : summaries) { + LocalDate statisticsDate = parseExportDate(summary, fixedDate); + if (statisticsDate == null) { + log.warn("{}:销售导出汇总缺少业务日期,hourType:{},跳过该记录", TAG, summary.getHourType()); + continue; + } + String summaryKey = exportSummaryGroupKey(summary); + 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 = exportGroupKey(merchant); + List detailRows = productGroups + .getOrDefault(merchantKey, List.of()).stream() + .flatMap(product -> toExportProductRows(product, + menuMap.get(exportMenuKey(product, statisticsDate))).stream()) + .toList(); + List statisticRows = buildExportProductStatistics(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(statisticsDate); + summaryRow.setSummaryHourType(resolveHourName(summary.getHourType(), hourNames)); + summaryRow.setSummarySaleSourceName(resolveExportSourceName(summary.getSaleSource())); + summaryRow.setSummaryPayUserCount(valueOrZero(summary.getPayUserCount())); + summaryRow.setSummaryTimeCount(valueOrZero(summary.getTimeCount())); + summaryRow.setSummaryPayCount(summaryProductCount); + summaryRow.setSummaryTotalPrice(centsToYuan(summaryTotalPrice)); + summaryRow.setRowLevel("SUMMARY"); + rows.add(summaryRow); + for (HardCanteenProductApptExportRecord merchant : merchantRows) { + String merchantKey = exportGroupKey(merchant); + HardCanteenProductApptExcelVo merchantRow = new HardCanteenProductApptExcelVo(); + merchantRow.setMerchantName(merchantNames.getOrDefault(merchant.getMerchantId(), "商户信息未记录")); + merchantRow.setDepartmentName(departmentNames.getOrDefault(merchant.getUserDeptId(), "部门信息未记录")); + merchantRow.setMerchantDate(statisticsDate); + merchantRow.setMerchantHourType(resolveHourName(summary.getHourType(), hourNames)); + merchantRow.setMerchantPayUserCount(valueOrZero(merchant.getPayUserCount())); + merchantRow.setMerchantTimeCount(valueOrZero(merchant.getTimeCount())); + merchantRow.setMerchantPayCount(detailCountsByMerchant.getOrDefault( + merchantKey, valueOrZero(merchant.getPayCount()))); + merchantRow.setMerchantTotalPrice(centsToYuan(detailPricesByMerchant.getOrDefault( + merchantKey, valueOrZero(merchant.getTotalPrice())))); + merchantRow.setRowLevel("MERCHANT"); + rows.add(merchantRow); + rows.addAll(detailRowsByMerchant.getOrDefault(merchantKey, List.of())); + } + } + return rows; + } + + /** + * 从一次 SQL 返回的三级聚合记录中提取指定层级。 + * + * @param records 当前自然日的全部导出聚合记录;允许为空 + * @param recordLevel Mapper 返回的 SUMMARY、MERCHANT 或 PRODUCT 层级编码 + * @return 保持数据库稳定排序的指定层级记录 + */ + private List filterSalesExportRecords( + List records, String recordLevel) { + if (ObjectUtil.isEmpty(records)) { + return List.of(); + } + return records.stream().filter(record -> ObjectUtil.equals(record.getRecordLevel(), recordLevel)).toList(); + } + + /** + * 将商品聚合行按业务当天每日菜谱中的套餐组成展开。 + * + *

组成商品数量按套餐数量乘组成份数计算,金额按菜谱单价权重使用最大余数法分摊,保证组成行金额 + * 之和严格等于订单快照金额。找不到套餐菜谱时保留原商品行。

+ * + * @param product 商品聚合记录 + * @param menu 与商户、日期、餐段和商品匹配的每日菜谱 + * @return 原商品行或套餐组成商品行 + */ + private List toExportProductRows( + HardCanteenProductApptExportRecord product, DailyMenuAppVO menu) { + List items = menu != null && ObjectUtil.isNotEmpty(menu.getComboItems()) + ? menu.getComboItems() : List.of(); + if (items.isEmpty()) { + return List.of(toExportProductRow(product.getProductName(), valueOrZero(product.getPayCount()), + valueOrZero(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<>(); + 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 { + itemPrices[items.size() - 1] = absoluteTotalCents; + } + List rows = new ArrayList<>(); + for (int index = 0; index < items.size(); index++) { + OnlineSpuComboItemVO item = items.get(index); + long itemCount = packageCount * Math.max(1, valueOrZero(item.getQuantity())); + rows.add(toExportProductRow(StrUtil.blankToDefault(item.getName(), "未命名商品"), + itemCount, itemPrices[index] * sign)); + } + return rows; + } + + /** + * 创建一个商品明细导出行。 + * + * @param productName 商品展示名称 + * @param productCount 有效商品数量 + * @param totalPrice 商品总价,单位为分 + * @return 只填写商品明细列的导出行 + */ + private HardCanteenProductApptExcelVo toExportProductRow(String productName, long productCount, + long totalPrice) { + HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo(); + row.setProductName(StrUtil.blankToDefault(productName, "未命名商品")); + row.setProductCount(productCount); + row.setProductTotalPrice(centsToYuan(totalPrice)); + row.setRowLevel("PRODUCT"); + return row; + } + + /** + * 在当前商户部门商品明细右侧生成按名称合并的商品统计。 + * + * @param detailRows 已扣减有效退款并完成套餐展开的商品明细 + * @return 按规范化商品名称聚合的商品统计行 + */ + private List buildExportProductStatistics( + List detailRows) { + Map statistics = new LinkedHashMap<>(); + for (HardCanteenProductApptExcelVo detailRow : detailRows) { + String displayName = StrUtil.blankToDefault(detailRow.getProductName(), "未命名商品").trim(); + String statisticKey = normalizeExportProductName(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)); + row.setRowLevel("PRODUCT_STATISTIC"); + 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 products 当前日期的商品聚合记录 + * @param statisticsDate 当前业务发生日期 + * @return 商户、日期、餐段和商品主键到每日菜谱的映射;远程查询失败的项不写入 + */ + private Map fetchExportDailyMenus( + List products, LocalDate statisticsDate) { + Map result = new HashMap<>(); + if (statisticsDate == null || ObjectUtil.isEmpty(products)) { + return result; + } + List merchantIds = products.stream().map(HardCanteenProductApptExportRecord::getMerchantId) + .filter(Objects::nonNull).distinct().toList(); + for (Long merchantId : merchantIds) { + List hourTypes = products.stream() + .filter(item -> Objects.equals(item.getMerchantId(), merchantId)) + .map(HardCanteenProductApptExportRecord::getHourType) + .filter(StrUtil::isNotBlank).distinct().toList(); + for (String hourType : hourTypes) { + try { + R> response = remoteDailyMenuService.getCanteenList( + statisticsDate.toString(), Integer.valueOf(hourType), null, merchantId); + if (response != null && response.isOk() && response.getData() != null) { + response.getData().forEach(menu -> { + if (menu.getSpuId() != null) { + result.put(merchantId + "|" + statisticsDate + "|" + hourType + "|" + + menu.getSpuId(), menu); + } + }); + } + } catch (Exception exception) { + log.warn("{}:查询套餐组成失败,保留原商品行,merchantId:{},date:{},hourType:{}", + TAG, merchantId, statisticsDate, hourType, exception); } } } @@ -653,20 +1204,212 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { } /** - * 解析部门导出的具体销售来源名称。 + * 批量补全三级导出中的食堂名称。 * - *

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

- * - * @param row 待导出的部门餐段统计行 - * @param dto 已完成来源标准化的统计请求 - * @return 可追溯的具体来源中文名称;来源异常时返回空值 + * @param rows 商户部门聚合记录 + * @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(); + private Map fetchExportMerchantNames(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("{}:查询导出食堂名称失败", TAG, exception); + return Map.of(); + } + } + + /** + * 批量补全三级导出中的用户部门名称。 + * + * @param rows 商户部门聚合记录 + * @return 部门主键到名称的映射;远程查询失败时返回空映射 + */ + private Map fetchExportDepartmentNames(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("{}:查询导出部门名称失败", TAG, exception); + return Map.of(); + } + } + + /** + * 复制三级导出查询,避免按日处理时改写控制器接收的时间范围。 + * + * @param source 已完成日期、来源和食堂权限校验的原请求 + * @return 可独立修改统计日期范围的请求副本 + */ + private HardOrderDto copyExportQuery(HardOrderDto source) { + HardOrderDto target = BeanUtil.toBean(source, HardOrderDto.class); + target.setStatisticsDateTimeList(source.getStatisticsDateTimeList() == null + ? null : source.getStatisticsDateTimeList().clone()); + target.setSaleSourceCodes(source.getSaleSourceCodes() == null + ? null : new ArrayList<>(source.getSaleSourceCodes())); + target.setMchIds(source.getMchIds() == null ? null : new ArrayList<>(source.getMchIds())); + target.setDeptIds(source.getDeptIds() == null ? null : new ArrayList<>(source.getDeptIds())); + target.setUserDeptIds(source.getUserDeptIds() == null + ? null : new ArrayList<>(source.getUserDeptIds())); + target.setMerchantDeptIds(source.getMerchantDeptIds() == null + ? null : new ArrayList<>(source.getMerchantDeptIds())); + target.setScopeDeptIds(source.getScopeDeptIds() == null + ? null : new ArrayList<>(source.getScopeDeptIds())); + return target; + } + + /** + * 将导出统计日期闭区间展开为自然日列表。 + * + * @param query 已通过日期范围校验的导出请求 + * @return 从开始日期到结束日期的有序闭区间 + */ + private List getExportDates(HardOrderDto query) { + LocalDate start = query.getStatisticsDateTimeList()[0].toLocalDate(); + LocalDate end = query.getStatisticsDateTimeList()[1].toLocalDate(); + List dates = new ArrayList<>(); + for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) { + dates.add(date); + } + return dates; + } + + /** + * 解析导出记录的业务日期。 + * + * @param row 日期餐段聚合记录 + * @param fixedDate 当前按日批次的已知日期 + * @return 可用业务日期;两者均为空或格式错误时返回空 + */ + private LocalDate parseExportDate(HardCanteenProductApptExportRecord row, LocalDate fixedDate) { + if (fixedDate != null) { + return fixedDate; + } + try { + return StrUtil.isBlank(row.getStatisticsDate()) ? null : LocalDate.parse(row.getStatisticsDate()); + } catch (Exception exception) { + return null; + } + } + + /** + * 构造日期餐段及来源口径分组键。 + * + * @param record 任一层级的销售聚合记录 + * @return 日期、餐段和来源组成的稳定键,空来源表示跨来源合计 + */ + private String exportSummaryGroupKey(HardCanteenProductApptExportRecord record) { + return record.getStatisticsDate() + "|" + record.getHourType() + "|" + + ObjectUtil.defaultIfNull(record.getSaleSource(), "TOTAL"); + } + + /** + * 构造商户部门及来源口径分组键。 + * + * @param record 商户部门或商品聚合记录 + * @return 日期、餐段、来源、商户和部门组成的稳定键 + */ + private String exportGroupKey(HardCanteenProductApptExportRecord record) { + return exportSummaryGroupKey(record) + "|" + record.getMerchantId() + "|" + record.getUserDeptId(); + } + + /** + * 构造每日菜谱套餐缓存键。 + * + * @param record 商品聚合记录 + * @param statisticsDate 业务发生日期 + * @return 商户、日期、餐段和商品主键组成的稳定键 + */ + private String exportMenuKey(HardCanteenProductApptExportRecord record, LocalDate statisticsDate) { + return record.getMerchantId() + "|" + statisticsDate + "|" + record.getHourType() + + "|" + record.getProductId(); + } + + /** + * 解析来源口径中文名称。 + * + * @param saleSource 销售来源协议编码;为空表示所选来源合计 + * @return 合计、线下购买、预约核销或未知来源文案 + */ + private String resolveExportSourceName(String saleSource) { + if (StrUtil.isBlank(saleSource)) { + return "合计"; + } + HardSaleSourceEnum source = HardSaleSourceEnum.valueOfCode(saleSource); + return source == null ? "未知来源" : source.getDescription(); + } + + /** + * 解析餐段名称,字典缺失时保留协议编码。 + * + * @param hourType 餐段协议编码 + * @param hourNames 餐段字典映射 + * @return 餐段名称或原编码 + */ + private String resolveHourName(String hourType, Map hourNames) { + return StrUtil.blankToDefault(hourNames.get(hourType), StrUtil.blankToDefault(hourType, "未标记餐段")); + } + + /** + * 规范化商品名称中的换行和连续空白。 + * + * @param productName 商品展示名称 + * @return 可用于同名商品聚合的稳定名称 + */ + private String normalizeExportProductName(String productName) { + return productName.replaceAll("\\s+", " ").trim(); + } + + /** + * 将空数值按零处理。 + * + * @param value 可空数量或金额 + * @return 原数值的长整型表示,空值返回零 + */ + private long valueOrZero(Number value) { + return value == null ? 0L : value.longValue(); + } + + /** + * 将分转换为保留两位小数的元。 + * + * @param cents 金额,单位为分 + * @return 金额,单位为元 + */ + private BigDecimal centsToYuan(Number cents) { + return BigDecimal.valueOf(valueOrZero(cents)).movePointLeft(2) + .setScale(2, RoundingMode.HALF_UP); + } + + /** + * 将两位小数的元转换为整数分,供商品明细重新累计合计。 + * + * @param yuan 金额,单位为元 + * @return 金额,单位为分;空值返回零 + */ + private long yuanToCents(BigDecimal yuan) { + return yuan == null ? 0L + : yuan.movePointRight(2).setScale(0, RoundingMode.HALF_UP).longValue(); } /** @@ -677,25 +1420,20 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { 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)); + 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,兼容历史页面和其他服务调用方。 * 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 a70c072a..db9b1538 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 @@ -91,6 +91,7 @@ import com.bsd.pjcloud.hard.mapper.HardThirdMiOrderMapper; import com.bsd.pjcloud.hard.mapper.order.HardMessageOutboxMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.*; +import com.bsd.pjcloud.hard.support.HardUserIdentitySupport; import com.bsd.pjcloud.supermarket.api.feign.RemoteMallProductService; import com.bsd.pjcloud.supermarket.api.feign.RemoteShopSkuService; import com.bsd.pjcloud.supermarket.api.feign.RemoteSupermarketSpuService; @@ -217,6 +218,8 @@ public class HardOrderServiceImpl extends ServiceImpl pagejoin(Page page, HardOrderDto dto) { + applyIdentityQuery(dto); Page list = baseMapper.pageJoin(page, dto); disposeList(list.getRecords(), dto); return list; @@ -251,6 +255,7 @@ public class HardOrderServiceImpl extends ServiceImpl scopeList(HardOrderDto dto, DataScope dataScope) { + applyIdentityQuery(dto); List list = baseMapper.scopeList(dto, dataScope); disposeList(list, dto); return list; @@ -305,6 +310,7 @@ public class HardOrderServiceImpl extends ServiceImpl scopePageList(Page page, HardOrderDto dto, DataScope dataScope) { + applyIdentityQuery(dto); if (dto.getAppViewerUserId() != null) { Page list = appVisibilityPageList(page, dto, dataScope); disposeList(list.getRecords(), dto); @@ -351,6 +357,7 @@ public class HardOrderServiceImpl extends ServiceImpl noRecogScopePageJoin(Page page, HardOrderDto dto, DataScope dataScope) { + applyIdentityQuery(dto); dto.setOrderPayTypeCodes(List.of(HardPayTypeEnum.HARD_DOOR_FREE_PAY.getType())); dto.setOrderStatus(HardOrderStatusEnum.CREATE.getType()); Page list = baseMapper.scopePageList(page, dto, dataScope); @@ -371,10 +378,7 @@ public class HardOrderServiceImpl extends ServiceImpl findUserFuture = CompletableFuture.runAsync(() -> { RequestContextHolder.setRequestAttributes(requestAttributes); if (ObjectUtil.isNotEmpty(detail.getUserId())) { - List userVoList = userService.getUserDeptByIds(List.of(detail.getUserId())).getData(); - if (ObjectUtil.isNotEmpty(userVoList) && ObjectUtil.isNotEmpty(userVoList.get(0))) { - DataUtil.convertUserDept(detail, userVoList.get(0)); - } + userIdentitySupport.fillUserInfo(List.of(detail), true); } }); // 商户 @@ -4839,20 +4843,7 @@ public class HardOrderServiceImpl extends ServiceImpl deptFuture = CompletableFuture.runAsync(() -> { RequestContextHolder.setRequestAttributes(requestAttributes); - List userIds = list.stream().map(HardOrderVo::getUserId).filter(ObjectUtil::isNotEmpty).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - List userList = userService.getUserDeptByIds(userIds).getData(); - if (ObjectUtil.isNotEmpty(userList)) { - Map map = CollectionUtils.convertMap(userList, UserVO::getUserId); - for (HardOrderVo item : list) { - UserVO userVO = map.get(item.getUserId()); - if (ObjectUtil.isEmpty(userVO)) continue; - item.setUsername(userVO.getName()); - item.setUserMobile(userVO.getPhone()); - item.setUserAvatar(userVO.getAvatar()); - } - } - } + userIdentitySupport.fillUserInfo(list, true); }); // 当前部门 CompletableFuture currentDeptFuture = CompletableFuture.runAsync(() -> { @@ -4935,4 +4926,21 @@ public class HardOrderServiceImpl extends ServiceImpl scopePageJoin(Page page, HardSpuRepertoryDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); Page pageList = baseMapper.scopePageJoin(page, dto, dataScope); disposeList(pageList.getRecords(), dto); return pageList; @@ -246,11 +244,20 @@ public class HardSpuRepertoryServiceImpl extends ServiceImpl userIds = list.stream().map(HardSpuRepertoryVo::getUserId).distinct().toList(); - R> userList = remoteUserService.getUserById(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserList(list, CollectionUtils.convertMap(userList.getData(), SysUser::getUserId)); - } + // 用户身份、姓名、手机号、头像及部门统一由 UPMS 公共装配器回填。 + userIdentitySupport.fillUserInfo(list, true); } -} \ No newline at end of file + + /** 将库存上架记录的身份条件转换为最终用户主键集合,保持原有 userIds SQL 过滤契约。 */ + private void prepareIdentityQuery(HardSpuRepertoryDto dto) { + if (!userIdentitySupport.hasIdentityQuery(dto)) { + return; + } + dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds())); + dto.setUserId(null); + dto.setIdentityTypes(null); + dto.setStudentInfo(null); + dto.setTeacherInfo(null); + dto.setHasFace(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardAuthMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardAuthMapper.xml index 6a263393..32d14f06 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardAuthMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardAuthMapper.xml @@ -41,6 +41,12 @@ and a.user_id = #{dto.userId} + + and a.user_id in + + #{v} + + and a.hard_id = #{dto.hardId} @@ -70,4 +76,4 @@ order by a.create_time desc , a.id desc - \ No newline at end of file + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardBroadcastMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardBroadcastMapper.xml index 80964a43..78dc2ab0 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardBroadcastMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardBroadcastMapper.xml @@ -51,6 +51,12 @@ and a.user_id = #{dto.userId} + + and a.user_id in + + #{v} + + and a.hard_id = #{dto.hardId} @@ -87,4 +93,4 @@ order by a.create_time desc, a.id desc - \ No newline at end of file + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardInvoiceMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardInvoiceMapper.xml index 9aee10fe..c404588d 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardInvoiceMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardInvoiceMapper.xml @@ -84,4 +84,4 @@ order by a.create_time desc, a.id desc - \ No newline at end of file + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleMapper.xml index 89a5f2b8..1d88329d 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleMapper.xml @@ -101,4 +101,4 @@ order by a.create_time desc,a.id desc - \ No newline at end of file + 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 1237b604..f10256ab 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,21 @@ + + + ( + 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 + + + + + and o.merchant_id = #{dto.merchantId} + + + and o.merchant_id in + + #{v} + + + + and o.hour_type = #{dto.hourType} + + + + + and o.dept_id in + + + and o.dept_id not in + + + + #{v} + + + + + + and o.user_dept_id in + + + and o.user_dept_id not in + + + + #{v} + + + + + + + + and a.product_name like concat('%', #{dto.productName}, '%') + + + + + + ( + select refund_item.parent_car_id, + refund_item.product_id, + sum(ifnull(refund_item.product_count, 0)) as product_count, + sum(ifnull(refund_item.total_price, 0)) as total_price + from hard_after_sale_car refund_item + inner join hard_after_sale refund_order on refund_order.id = refund_item.refund_id + and refund_order.del_flag = '0' and refund_order.status = '50' + inner join hard_order o on o.parent_car_id = refund_item.parent_car_id + and o.del_flag = '0' and o.merchant_type = '1' and o.order_type = '2' + and (o.appt_id is not null or o.appt_rule_id is not null) + and o.order_status = '20' and o.verification_time is not null + and o.hour_type is not null and o.hour_type not in ('-1') + where refund_item.del_flag = '0' + + and o.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]} + + + group by refund_item.parent_car_id, refund_item.product_id + ) refund_car + + - ( + + select DATE_FORMAT(o.pay_time, '%Y-%m-%d') as stat_date, o.pay_time as stat_time, o.id as order_id, @@ -142,7 +228,14 @@ where a.del_flag = '0' and o.pay_time is not null and o.order_status in ('5', '20') + + and o.pay_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]} + + + 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, @@ -153,13 +246,13 @@ o.merchant_dept_id, o.hour_type, a.product_id, - a.product_name, - a.product_pic, - a.product_price, - a.product_count, - a.total_price, + max(a.product_name) as product_name, + max(a.product_pic) as product_pic, + max(a.product_price) as product_price, + greatest(sum(ifnull(a.product_count, 0)) - ifnull(refund_car.product_count, 0), 0) as product_count, + greatest(sum(ifnull(a.total_price, 0)) - ifnull(refund_car.total_price, 0), 0) as total_price, 'RESERVATION_VERIFIED' as sale_source - from + from hard_car a inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0' inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0' and o.merchant_type = '1' and o.order_type = '2' @@ -167,7 +260,20 @@ and o.order_status = '20' and o.verification_time is not null and o.hour_type is not null and o.hour_type not in ('-1') - ) sale_stats + left join + on refund_car.parent_car_id = a.parent_car_id + and refund_car.product_id = a.product_id + where a.del_flag = '0' + + and o.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]} + + + + group by o.id, o.user_id, o.merchant_id, o.user_dept_id, o.dept_id, o.merchant_dept_id, + o.hour_type, o.verification_time, a.product_id, refund_car.product_count, refund_car.total_price + having sum(ifnull(a.product_count, 0)) > ifnull(refund_car.product_count, 0) + + @@ -644,22 +750,32 @@ 综合菜品销售导出为每个日期餐段同时生成跨来源合计和来源拆分。 三层 SQL 使用相同的来源、业务时间、食堂和部门筛选,保证页面与导出以及上下层之间口径一致。 --> - - - - - @@ -318,4 +336,4 @@ - \ No newline at end of file + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeInvoiceMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeInvoiceMapper.xml index b12ec236..ff39cf63 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeInvoiceMapper.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeInvoiceMapper.xml @@ -84,7 +84,17 @@ - \ No newline at end of file + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/rule/WalletRuleTransactionMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/rule/WalletRuleTransactionMapper.xml index 3ca473bd..bfa30201 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/rule/WalletRuleTransactionMapper.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/rule/WalletRuleTransactionMapper.xml @@ -83,6 +83,12 @@ and t.user_id = #{dto.userId} + + and t.user_id in + + #{v} + + @@ -103,4 +109,4 @@ group by a.id order by a.create_time desc,a.id desc - \ No newline at end of file + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java index 11e14279..b9f71d52 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java @@ -2,6 +2,7 @@ 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.common.core.util.R; 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; @@ -31,7 +32,7 @@ class MchPaySettingServiceImplTest { @Mock private MchPaySettingMapper mchPaySettingMapper; - /** 商户服务 Mock,仅补齐异步刷新依赖。 */ + /** 商户服务 Mock,用于同步刷新默认平台分佣关系。 */ @Mock private MchManageService mchManageService; @@ -39,7 +40,7 @@ class MchPaySettingServiceImplTest { @Mock private RemoteUserService remoteUserService; - /** 第三方支付远程服务 Mock,仅补齐异步刷新依赖。 */ + /** 第三方支付远程服务 Mock,用于同步确认关系事实已接收。 */ @Mock private RemoterThirdAppPlusPayService thirdAppPlusPayService; @@ -81,6 +82,8 @@ class MchPaySettingServiceImplTest { MchPaySettingEntity stored = storedAliPaySetting(); when(mchPaySettingMapper.selectCount(any())).thenReturn(1L); when(mchPaySettingMapper.selectById(stored.getId())).thenReturn(stored); + when(mchManageService.join(stored.getMchId())).thenReturn(new com.bsd.pjcloud.wallet.api.vo.MchManageVo()); + when(thirdAppPlusPayService.remoterChangeMch(any())).thenReturn(R.ok(true)); MchPaySettingEntity.AliPaySetting incomingSetting = new MchPaySettingEntity.AliPaySetting(); incomingSetting.setSubMchid("smid-1001"); incomingSetting.setAccountKey("secondary"); From 388ef914fcae2fd441fcec0cbe48d491d904a348 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 25 Aug 2026 13:07:03 +0800 Subject: [PATCH 02/31] 11 --- .../hard/api/dto/spu/HardSpuRepertoryDto.java | 5 +- .../hard/api/vo/order/HardAfterSaleVo.java | 8 +- .../hard/api/vo/order/HardOrderVo.java | 3 + .../hard/api/vo/spu/HardSpuRepertoryVo.java | 22 +- .../impl/spu/HardSpuRepertoryServiceImpl.java | 21 +- .../hard/support/HardUserIdentitySupport.java | 255 ++++++++++++ .../ThirdMchFzRelationServiceImplTest.java | 366 ++++++++++++++++++ .../api/vo/base/BaseUserDeptInterface.java | 7 +- .../vo/base/BaseUserIdentityInterface.java | 19 +- .../api/vo/base/BaseUserPhoneInterface.java | 24 ++ .../wallet/api/dto/WalletCodeUseDto.java | 19 +- .../wallet/api/vo/WalletCodeUseVo.java | 38 +- .../pjcloud/wallet/api/vo/WalletManageVo.java | 3 +- .../vo/transaction/WalletTransactionVo.java | 3 +- .../impl/WalletCodeUseServiceImpl.java | 41 +- .../service/impl/WalletManageServiceImpl.java | 4 +- 16 files changed, 793 insertions(+), 45 deletions(-) create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/support/HardUserIdentitySupport.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java create mode 100644 pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserPhoneInterface.java diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/spu/HardSpuRepertoryDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/spu/HardSpuRepertoryDto.java index 5e62601a..eebb0bd5 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/spu/HardSpuRepertoryDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/spu/HardSpuRepertoryDto.java @@ -9,14 +9,17 @@ import lombok.Data; import java.time.LocalDateTime; import java.util.List; -@Data /** 硬件商品库存更新记录查询条件,支持按上架用户身份快速筛选。 */ +@Data public class HardSpuRepertoryDto extends HardSpuRepertoryEntity implements BaseUserIdentityQueryInterface { + /** 库存更新记录创建时间闭区间;数组长度为 2 时生效。 */ private LocalDateTime[] createTimeList; + /** 身份预筛选后用于库存记录查询的上架用户主键集合。 */ private List userIds; + /** 目标柜门主键;为空时不限制柜门。 */ private Long cabinetId; /** UPMS 用户身份协议编码集合;为空表示不限制身份。 */ 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 e265efba..c4a1faac 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 @@ -5,6 +5,7 @@ import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface; import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface; import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface; import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity; import com.bsd.pjcloud.hard.api.vo.base.BaseOrderInterface; import lombok.Data; @@ -12,8 +13,13 @@ import lombok.Data; import java.time.LocalDateTime; import java.util.List; +/** + * 硬件订单售后详情。 + * + *

同时保留历史用户、用户部门和用户身份协议,兼容售后详情的旧用户装配逻辑及管理端身份展示。

+ */ @Data -public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface, BaseOrderInterface { +public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserInterface, BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface, BaseOrderInterface { /** * 自动退款任务状态;为空表示当前售后仍走人工审核,非空时前端不得展示审核或取消操作。 */ 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 afa0bf53..1fe36132 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 @@ -30,6 +30,9 @@ public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterfac private String userMobile; + /** 用户标准手机号字段,供统一身份组件展示;与历史 userMobile 字段并存。 */ + private String phone; + private String userAvatar; /** 用户身份协议编码集合;没有身份时为空集合或空值。 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/spu/HardSpuRepertoryVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/spu/HardSpuRepertoryVo.java index d9157f4b..58a369c3 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/spu/HardSpuRepertoryVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/spu/HardSpuRepertoryVo.java @@ -10,44 +10,58 @@ import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface; import com.bsd.pjcloud.wallet.api.vo.base.BaseMchInterface; import lombok.Data; -@Data +import java.util.List; + /** 硬件商品库存更新记录展示对象,包含上架用户主资料、部门和身份信息。 */ +@Data public class HardSpuRepertoryVo extends HardSpuRepertoryEntity implements BaseMchInterface, BaseUserInterface, BaseUserDeptInterface, BaseUserIdentityInterface, BaseHardInterface { /** * 商品相关 */ + /** 本次更新对应的商品名称。 */ private String spuName; + /** 商品分类主键。 */ private Long spuCategoryId; + /** 商品所属商户主键。 */ private Long spuMchId; + /** 商品所在硬件柜门数量。 */ private Long hardDoorNumber; /** * 分类相关 */ + /** 商品分类名称。 */ private String spuCategoryName; /** * 商户相关 */ + /** 商品所属商户主键,供公共商户展示契约读取。 */ private Long merchantId; + /** 商品所属商户名称。 */ private String merchantName; + /** 商品所属商户 Logo 地址。 */ private String logoPic; + /** 商品所属商户类型协议编码。 */ private String merchantType; /** * 用户相关 */ + /** 上架用户姓名,保留历史接口字段名。 */ private String username; + /** 上架用户手机号,保留历史接口字段名。 */ private String userMobile; + /** 上架用户头像地址,保留历史接口字段名。 */ private String userAvatar; /** 用户标准手机号字段,供统一身份组件展示;与历史 userMobile 字段并存。 */ @@ -63,7 +77,7 @@ public class HardSpuRepertoryVo extends HardSpuRepertoryEntity implements BaseMc private String deptName; /** 用户拥有的 UPMS 身份协议编码集合。 */ - private java.util.List identityTypes; + private List identityTypes; /** 用户的学生身份扩展资料;无学生身份时为空。 */ private SysUserStudentDto studentInfo; @@ -99,12 +113,16 @@ public class HardSpuRepertoryVo extends HardSpuRepertoryEntity implements BaseMc /** * 设备相关 */ + /** 执行库存更新的硬件主键。 */ private Long hardId; + /** 执行库存更新的硬件名称。 */ private String hardName; + /** 执行库存更新的硬件类型协议编码。 */ private String hardType; + /** 执行库存更新的硬件设备编号。 */ private String deviceCode; } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/spu/HardSpuRepertoryServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/spu/HardSpuRepertoryServiceImpl.java index 500e9b8e..2360c6a6 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/spu/HardSpuRepertoryServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/spu/HardSpuRepertoryServiceImpl.java @@ -29,6 +29,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; +import java.util.ArrayList; import java.util.List; /** @@ -49,6 +50,14 @@ public class HardSpuRepertoryServiceImpl extends ServiceImpl scopePageJoin(Page page, HardSpuRepertoryDto dto, DataScope dataScope) { prepareIdentityQuery(dto); @@ -233,6 +242,12 @@ public class HardSpuRepertoryServiceImpl extends ServiceImpl list, HardSpuRepertoryDto dto) { if (ObjectUtil.isEmpty(list)) return; // 商户 @@ -248,7 +263,11 @@ public class HardSpuRepertoryServiceImpl extends ServiceImpl订单等业务表只保存用户主键和必要快照,身份筛选统一交给 UPMS 数据权限用户接口处理, + * Hardware 仅将命中的用户主键并入原有查询,并为当前页批量回填身份展示资料。

+ */ +@Component +@RequiredArgsConstructor +public class HardUserIdentitySupport { + + /** 单次调用 UPMS 查询的最大用户数量,控制跨服务响应体和内存占用。 */ + private static final long USER_BATCH_SIZE = 500L; + + /** 身份条件无匹配用户时使用的无效用户主键,确保下游忽略空集合的 SQL 不会放宽查询。 */ + private static final Long NO_MATCH_USER_ID = -1L; + + /** UPMS 用户远程服务,负责数据权限、身份筛选及身份资料返回。 */ + private final RemoteUserService remoteUserService; + + /** + * 判断查询对象是否携带实际生效的身份或人脸条件。 + * + * @param query Hardware 业务查询对象;为空表示没有身份条件 + * @return 至少存在一个有效身份、人脸或身份扩展条件时返回 {@code true} + */ + public boolean hasIdentityQuery(BaseUserIdentityQueryInterface query) { + if (query == null) { + return false; + } + return query.getIdentityTypes() != null && !query.getIdentityTypes().isEmpty() + || hasStudentQuery(query.getStudentInfo()) + || hasTeacherQuery(query.getTeacherInfo()) + || StrUtil.isNotBlank(query.getHasFace()); + } + + /** + * 在现有用户条件范围内查询符合身份条件的用户主键。 + * + * @param query 身份及人脸查询条件 + * @param userId 页面已有的单用户筛选条件 + * @param userIds 页面已有的多用户筛选条件 + * @return 同时符合已有用户范围、当前操作者数据权限和身份条件的用户主键;无匹配时返回无效主键 + */ + public List findIdentityUserIds(BaseUserIdentityQueryInterface query, Long userId, List userIds) { + List constrainedUserIds = mergeUserConstraints(userId, userIds); + if (constrainedUserIds != null && constrainedUserIds.isEmpty()) { + return List.of(NO_MATCH_USER_ID); + } + UserDTO userQuery = buildUserQuery(query, constrainedUserIds); + List users = fetchAllUsers(userQuery); + if (users.isEmpty()) { + return List.of(NO_MATCH_USER_ID); + } + return users.stream().map(UserVO::getUserId).filter(Objects::nonNull).distinct().toList(); + } + + /** + * 批量补齐 Hardware 列表的用户主资料和身份资料,并按需更新当前用户部门。 + * + * @param rows 当前页 Hardware 业务记录 + * @param updateDepartment 是否使用 UPMS 当前部门覆盖业务记录中的部门快照 + * @param 同时实现用户部门和用户身份展示契约的业务 VO 类型 + */ + public void fillUserInfo(List rows, + boolean updateDepartment) { + if (rows == null || rows.isEmpty()) { + return; + } + List userIds = rows.stream().map(BaseUserDeptInterface::getUserId).filter(Objects::nonNull).distinct().toList(); + if (userIds.isEmpty()) { + return; + } + UserDTO userQuery = new UserDTO(); + userQuery.setUserIds(userIds); + Map userMap = CollectionUtils.convertMap(fetchAllUsers(userQuery), UserVO::getUserId); + for (T row : rows) { + UserVO user = userMap.get(row.getUserId()); + if (user == null) { + continue; + } + // 部分历史 VO 显式声明了标准字段,需同时回填旧字段以兼容既有接口与导出。 + row.setUsername(user.getName()); + row.setUserMobile(user.getPhone()); + row.setUserAvatar(user.getAvatar()); + row.setName(user.getName()); + row.setPhone(user.getPhone()); + row.setAvatar(user.getAvatar()); + if (updateDepartment) { + row.setUserDeptId(user.getDeptId()); + row.setUserDeptName(user.getDeptName()); + row.setDeptName(user.getDeptName()); + } + row.setIdentityTypes(copyList(user.getIdentityTypes())); + row.setStudentInfo(copyStudentInfo(user.getStudentInfo())); + row.setTeacherInfo(copyTeacherInfo(user.getTeacherInfo())); + } + } + + /** + * 合并单用户和多用户条件;两者同时存在时取交集,保持原查询的“同时满足”语义。 + * + * @param userId 单用户主键 + * @param userIds 多用户主键集合 + * @return 去重后的用户限制;返回 {@code null} 表示未限制用户,空集合表示条件互斥 + */ + private List mergeUserConstraints(Long userId, List userIds) { + LinkedHashSet constraints = new LinkedHashSet<>(); + if (userIds != null && !userIds.isEmpty()) { + userIds.stream().filter(Objects::nonNull).forEach(constraints::add); + } + if (userId == null) { + return constraints.isEmpty() ? null : new ArrayList<>(constraints); + } + if (constraints.isEmpty()) { + return List.of(userId); + } + return constraints.contains(userId) ? List.of(userId) : List.of(); + } + + /** + * 把 Hardware 查询契约转换为 UPMS 用户查询对象,并复制可变身份条件。 + * + * @param query Hardware 侧身份查询条件 + * @param constrainedUserIds 已有用户筛选范围 + * @return 可独立交给 UPMS 使用的用户查询对象 + */ + private UserDTO buildUserQuery(BaseUserIdentityQueryInterface query, List constrainedUserIds) { + UserDTO userQuery = new UserDTO(); + userQuery.setIdentityTypes(copyList(query.getIdentityTypes())); + userQuery.setStudentInfo(copyStudentInfo(query.getStudentInfo())); + userQuery.setTeacherInfo(copyTeacherInfo(query.getTeacherInfo())); + userQuery.setHasFace(query.getHasFace()); + userQuery.setUserIds(copyList(constrainedUserIds)); + return userQuery; + } + + /** + * 分页读取当前操作者权限范围内的全部匹配用户。 + * + * @param query UPMS 用户查询条件 + * @return 按 UPMS 分页顺序合并的用户列表 + */ + private List fetchAllUsers(UserDTO query) { + List users = new ArrayList<>(); + long current = 1L; + long total; + do { + R> response = remoteUserService.getScopeBasicUserPage(current, USER_BATCH_SIZE, query); + if (response == null || !response.isOk() || response.getData() == null) { + String message = response == null ? null : response.getMsg(); + throw new IllegalStateException(StrUtil.blankToDefault(message, "查询权限用户及身份信息失败")); + } + Page page = response.getData(); + 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 studentInfo 学生身份扩展条件 + * @return 学号、学校、班级或监护人手机号任一非空时返回 {@code true} + */ + private boolean hasStudentQuery(SysUserStudentDto studentInfo) { + return studentInfo != null && (StrUtil.isNotBlank(studentInfo.getStudentNo()) + || StrUtil.isNotBlank(studentInfo.getSchoolName()) + || StrUtil.isNotBlank(studentInfo.getClassName()) + || StrUtil.isNotBlank(studentInfo.getGuardianPhones())); + } + + /** + * 判断老师扩展对象是否包含有效筛选字段。 + * + * @param teacherInfo 老师身份扩展条件 + * @return 老师学号非空时返回 {@code true} + */ + private boolean hasTeacherQuery(SysUserTeacherDto teacherInfo) { + return teacherInfo != null && StrUtil.isNotBlank(teacherInfo.getTeacherNo()); + } + + /** + * 复制学生身份资料,避免跨服务响应对象被业务列表意外修改。 + * + * @param source 原学生身份资料 + * @return 独立副本;源对象为空时返回 {@code null} + */ + private SysUserStudentDto copyStudentInfo(SysUserStudentDto source) { + if (source == null) { + return null; + } + SysUserStudentDto target = new SysUserStudentDto(); + target.setStudentNo(source.getStudentNo()); + target.setSchoolName(source.getSchoolName()); + target.setClassName(source.getClassName()); + target.setGuardianPhones(source.getGuardianPhones()); + target.setExtJson(source.getExtJson()); + return target; + } + + /** + * 复制老师身份资料,保持查询与展示对象相互独立。 + * + * @param source 原老师身份资料 + * @return 独立副本;源对象为空时返回 {@code null} + */ + private SysUserTeacherDto copyTeacherInfo(SysUserTeacherDto source) { + if (source == null) { + return null; + } + SysUserTeacherDto target = new SysUserTeacherDto(); + target.setTeacherNo(source.getTeacherNo()); + return target; + } + + /** + * 复制可变列表并保持空值语义。 + * + * @param source 原列表 + * @param 列表元素类型 + * @return 独立列表;源列表为空值时返回 {@code null} + */ + private List copyList(List source) { + return source == null ? null : new ArrayList<>(source); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java new file mode 100644 index 00000000..6a02dc66 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java @@ -0,0 +1,366 @@ +package com.bsd.pjcloud.thirdparty.service.impl.pay; + +import cn.hutool.json.JSONUtil; +import com.alipay.api.AlipayClient; +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.Wrapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +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.ThirdPaymentTaskEnqueuer; +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.wechat.pay.java.service.profitsharing.ProfitsharingService; +import com.wechat.pay.java.service.profitsharing.model.AddReceiverResponse; +import com.wechat.pay.java.service.profitsharing.model.ReceiverRelationType; +import com.wechat.pay.java.service.profitsharing.model.ReceiverType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.springframework.dao.DuplicateKeyException; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 商户微信、支付宝共用默认平台分佣关系测试。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdMchFzRelationServiceImplTest { + + /** 关系事实 Mapper Mock,用于捕获关系快照并模拟唯一键竞争。 */ + @Mock + private ThirdMchFzRelationMapper relationMapper; + + /** 关系任务写入器 Mock,用于区分配置唤醒与支付流量确保任务。 */ + @Mock + private ThirdPaymentTaskEnqueuer taskEnqueuer; + + /** 微信分账 SDK Mock,不发起真实渠道请求。 */ + @Mock + private ProfitsharingService profitsharingService; + + /** 商户内部接口 Mock,用于支付宝 Worker 读取当前授权配置。 */ + @Mock + private RemoteMchService remoteMchService; + + /** 支付宝 SDK 客户端 Mock,用于验证网关成功与业务结果码的组合语义。 */ + @Mock + private AlipayClient alipayClient; + + /** 被测统一分账关系服务。 */ + private ThirdMchFzRelationServiceImpl service; + + /** 测试租户ID,作为关系唯一键和任务隔离边界。 */ + private static final Long TENANT_ID = 9L; + + /** 测试商户ID,对应 mch_manage 主键。 */ + private static final Long MCH_ID = 88L; + + /** 初始化平台默认接收账号与被测服务。 */ + @BeforeEach + void setUp() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), + ThirdMchFzRelationEntity.class); + AppPlusPayConfig config = new AppPlusPayConfig(); + AppPlusPayConfig.WechatConfig wechat = new AppPlusPayConfig.WechatConfig(); + wechat.setAppPlatformAppId("wx-app-1001"); + wechat.setMerchantId("1900000001"); + wechat.setProfitSharingReceiverAccount("1900000002"); + wechat.setProfitSharingReceiverName("平台服务商"); + config.setWechatConfig(wechat); + + AppPlusPayConfig.AliPayConfig alipay = new AppPlusPayConfig.AliPayConfig(); + alipay.setAppId("2026000000000001"); + alipay.setAppPrivateKey("test-private-key"); + alipay.setAlipayPublicKey("test-public-key"); + alipay.setRoyaltyTransInType("userId"); + alipay.setRoyaltyTransIn("2088000000000001"); + alipay.setRoyaltyTransInName("平台支付宝账号"); + config.setAliPayConfig(alipay); + service = new TestableThirdMchFzRelationServiceImpl(relationMapper, taskEnqueuer, + profitsharingService, config, remoteMchService, alipayClient); + } + + /** 微信和支付宝启用抽佣后分别生成一条默认关系,且请求快照不保存授权 Token。 */ + @Test + void syncDefaultRelationsShouldCreateWechatAndAlipayRelations() { + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(null); + MchPaySettingVo wechat = wechatSetting(101L, 3D); + MchPaySettingVo alipay = alipaySetting(102L, 5D, "sensitive-auth-token"); + + service.syncDefaultRelations(merchant(List.of(wechat, alipay))); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ThirdMchFzRelationEntity.class); + verify(relationMapper, times(2)).insert(captor.capture()); + List relations = captor.getAllValues(); + ThirdMchFzRelationEntity wechatRelation = relations.stream() + .filter(item -> AppPlusPlatformPayType.WECHAT.getType().equals(item.getPlatformType())) + .findFirst().orElseThrow(); + ThirdMchFzRelationEntity alipayRelation = relations.stream() + .filter(item -> AppPlusPlatformPayType.ALI_PAY.getType().equals(item.getPlatformType())) + .findFirst().orElseThrow(); + Assertions.assertAll( + () -> Assertions.assertEquals("wx-sub-1001", wechatRelation.getChannelSubMchId()), + () -> Assertions.assertEquals("1900000002", wechatRelation.getReceiverAccount()), + () -> Assertions.assertEquals(ReceiverType.MERCHANT_ID.name(), wechatRelation.getReceiverType()), + () -> Assertions.assertEquals(ReceiverRelationType.SERVICE_PROVIDER.name(), wechatRelation.getChannelRelationType()), + () -> Assertions.assertEquals("smid-1001", alipayRelation.getChannelSubMchId()), + () -> Assertions.assertEquals("2088000000000001", alipayRelation.getReceiverAccount()), + () -> Assertions.assertEquals("userId", alipayRelation.getReceiverType()), + () -> Assertions.assertFalse(alipayRelation.getBindReq().contains("sensitive-auth-token")), + () -> Assertions.assertTrue(alipayRelation.getBindReq().contains("appAuthTokenConfigured"))); + } + + /** 相同关系身份重复同步时复用既有记录,不重复插入渠道关系事实。 */ + @Test + void syncDefaultRelationsShouldReuseExistingIdentity() { + MchPaySettingVo setting = wechatSetting(101L, 3D); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(null); + service.syncDefaultRelations(merchant(List.of(setting))); + ArgumentCaptor captor = ArgumentCaptor.forClass(ThirdMchFzRelationEntity.class); + verify(relationMapper).insert(captor.capture()); + ThirdMchFzRelationEntity existing = captor.getValue(); + existing.setStatus(ThirdMchFzRelationStatusEnum.BOUND.getCode()); + existing.setBoundTime(LocalDateTime.now()); + + clearInvocations(relationMapper, taskEnqueuer); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(existing); + service.syncDefaultRelations(merchant(List.of(setting))); + + verify(relationMapper, never()).insert(any(ThirdMchFzRelationEntity.class)); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 并发插入命中唯一键时读取胜出记录继续处理,不把正常竞争暴露为配置保存失败。 */ + @Test + void syncDefaultRelationsShouldReuseWinnerAfterDuplicateKey() { + ThirdMchFzRelationEntity winner = pendingWechatRelation(7001L); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(null, winner); + doThrow(new DuplicateKeyException("duplicate relation")).when(relationMapper) + .insert(any(ThirdMchFzRelationEntity.class)); + + service.syncDefaultRelations(merchant(List.of(wechatSetting(101L, 3D)))); + + verify(taskEnqueuer).enqueueRelationBind(org.mockito.ArgumentMatchers.eq(TENANT_ID), + org.mockito.ArgumentMatchers.eq(7001L), any(), org.mockito.ArgumentMatchers.eq(150)); + } + + /** 关系尚未绑定时支付入口必须拒绝,并只确保稳定任务存在而不重置既有退避。 */ + @Test + void requireBoundShouldRejectAndEnsureTaskWithoutRearm() { + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(null).thenReturn(null); + + IllegalStateException exception = Assertions.assertThrows(IllegalStateException.class, + () -> service.requireBound(wechatSetting(101L, 3D))); + + Assertions.assertTrue(exception.getMessage().contains("尚未绑定")); + ArgumentCaptor relationCaptor = ArgumentCaptor.forClass(ThirdMchFzRelationEntity.class); + verify(relationMapper).insert(relationCaptor.capture()); + verify(taskEnqueuer).ensureRelationBind(org.mockito.ArgumentMatchers.eq(TENANT_ID), + org.mockito.ArgumentMatchers.eq(relationCaptor.getValue().getId()), any(), org.mockito.ArgumentMatchers.eq(150)); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 跨服务保存回滚留下的历史已绑定关系应按当前配置恢复,避免支付永久被拒绝。 */ + @Test + void requireBoundShouldRestoreMatchingHistoricalBoundRelation() { + ThirdMchFzRelationEntity historical = pendingWechatRelation(7004L); + historical.setStatus(ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode()); + historical.setIsDefault(YesNoEnum.NO.getCode()); + historical.setBoundTime(LocalDateTime.now()); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(null, historical); + + ThirdMchFzRelationEntity result = service.requireBound(wechatSetting(101L, 3D)); + + Assertions.assertSame(historical, result); + Assertions.assertEquals(ThirdMchFzRelationStatusEnum.BOUND.getCode(), result.getStatus()); + Assertions.assertEquals(YesNoEnum.YES.getCode(), result.getIsDefault()); + verify(taskEnqueuer, never()).ensureRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 微信返回其他接收方时不能把本地关系标记为已绑定。 */ + @Test + void bindWechatShouldRejectMismatchedResponse() { + ThirdMchFzRelationEntity pending = pendingWechatRelation(7002L); + ThirdMchFzRelationEntity binding = pendingWechatRelation(7002L); + binding.setStatus(ThirdMchFzRelationStatusEnum.BINDING.getCode()); + when(relationMapper.selectById(7002L)).thenReturn(pending, binding); + when(relationMapper.update(any(), any())).thenReturn(1); + AddReceiverResponse response = new AddReceiverResponse(); + response.setSubMchid(binding.getChannelSubMchId()); + response.setType(ReceiverType.MERCHANT_ID); + response.setAccount("different-receiver"); + response.setRelationType(ReceiverRelationType.SERVICE_PROVIDER); + when(profitsharingService.addReceiver(any())).thenReturn(response); + + Assertions.assertFalse(service.bind(7002L)); + verify(relationMapper, times(2)).update(any(), any()); + } + + /** 支付宝网关成功但业务 result_code 非 SUCCESS 时不得把关系误记为已绑定。 */ + @Test + void bindAlipayShouldRejectNonSuccessBusinessResult() throws Exception { + ThirdMchFzRelationEntity pending = pendingAlipayRelation(7003L); + ThirdMchFzRelationEntity binding = pendingAlipayRelation(7003L); + binding.setStatus(ThirdMchFzRelationStatusEnum.BINDING.getCode()); + when(relationMapper.selectById(7003L)).thenReturn(pending, binding); + when(relationMapper.update(any(), any())).thenReturn(1); + when(remoteMchService.getById(MCH_ID)).thenReturn(com.bsd.pjcloud.common.core.util.R.ok( + merchant(List.of(alipaySetting(102L, 5D, "merchant-auth-token"))))); + + AlipayTradeRoyaltyRelationBindResponse bindResponse = new AlipayTradeRoyaltyRelationBindResponse(); + bindResponse.setCode("10000"); + bindResponse.setResultCode("FAIL"); + when(alipayClient.execute(any(AlipayTradeRoyaltyRelationBindRequest.class))).thenReturn(bindResponse); + AlipayTradeRoyaltyRelationBatchqueryResponse queryResponse = new AlipayTradeRoyaltyRelationBatchqueryResponse(); + queryResponse.setCode("10000"); + queryResponse.setResultCode("FAIL"); + when(alipayClient.execute(any(AlipayTradeRoyaltyRelationBatchqueryRequest.class))).thenReturn(queryResponse); + + Assertions.assertFalse(service.bind(7003L)); + verify(relationMapper, times(2)).update(any(), any()); + } + + /** 负数或超过百分之百的抽佣配置必须在关系物化前拒绝。 */ + @Test + void syncDefaultRelationsShouldRejectInvalidRatio() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> service.syncDefaultRelations(merchant(List.of(wechatSetting(101L, -1D))))); + Assertions.assertThrows(IllegalArgumentException.class, + () -> service.syncDefaultRelations(merchant(List.of(alipaySetting(102L, 101D, null))))); + verify(relationMapper, never()).insert(any(ThirdMchFzRelationEntity.class)); + } + + /** 构造商户关系同步快照。 */ + private MchManageVo merchant(List settings) { + MchManageVo merchant = new MchManageVo(); + merchant.setId(MCH_ID); + merchant.setTenantId(TENANT_ID); + merchant.setPaySettingList(settings); + return merchant; + } + + /** 构造启用抽佣的微信子商户支付配置。 */ + private MchPaySettingVo wechatSetting(Long id, Double ratio) { + MchPaySettingEntity.WechatPaySetting detail = new MchPaySettingEntity.WechatPaySetting(); + detail.setSubMchid("wx-sub-1001"); + return setting(id, AppPlusPlatformPayType.WECHAT.getType(), ratio, JSONUtil.toJsonStr(detail)); + } + + /** 构造启用抽佣的支付宝直付通支付配置。 */ + private MchPaySettingVo alipaySetting(Long id, Double ratio, String appAuthToken) { + MchPaySettingEntity.AliPaySetting detail = new MchPaySettingEntity.AliPaySetting(); + detail.setSubMchid("smid-1001"); + detail.setAppAuthToken(appAuthToken); + return setting(id, AppPlusPlatformPayType.ALI_PAY.getType(), ratio, JSONUtil.toJsonStr(detail)); + } + + /** 构造关系服务使用的通用支付配置字段。 */ + private MchPaySettingVo setting(Long id, String platformType, Double ratio, String settingJson) { + MchPaySettingVo setting = new MchPaySettingVo(); + setting.setId(id); + setting.setTenantId(TENANT_ID); + setting.setMchId(MCH_ID); + setting.setPlatformType(platformType); + setting.setEnable(YesNoEnum.YES.getCode()); + setting.setFzRatio(ratio); + setting.setSettingJson(settingJson); + return setting; + } + + /** 构造等待绑定的微信关系事实。 */ + private ThirdMchFzRelationEntity pendingWechatRelation(Long id) { + ThirdMchFzRelationEntity relation = new ThirdMchFzRelationEntity(); + relation.setId(id); + relation.setTenantId(TENANT_ID); + relation.setMchId(MCH_ID); + relation.setPaySettingId(101L); + relation.setPlatformType(AppPlusPlatformPayType.WECHAT.getType()); + relation.setChannelAppId("wx-app-1001"); + relation.setChannelSubMchId("wx-sub-1001"); + relation.setReceiverType(ReceiverType.MERCHANT_ID.name()); + relation.setReceiverAccount("1900000002"); + relation.setReceiverName("平台服务商"); + relation.setChannelRelationType(ReceiverRelationType.SERVICE_PROVIDER.name()); + relation.setStatus(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode()); + relation.setDelFlag("0"); + return relation; + } + + /** 构造等待绑定的支付宝直付通平台分佣关系事实。 */ + private ThirdMchFzRelationEntity pendingAlipayRelation(Long id) { + ThirdMchFzRelationEntity relation = new ThirdMchFzRelationEntity(); + relation.setId(id); + relation.setTenantId(TENANT_ID); + relation.setMchId(MCH_ID); + relation.setPaySettingId(102L); + relation.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType()); + relation.setChannelAppId("2026000000000001"); + relation.setChannelSubMchId("smid-1001"); + relation.setReceiverType("userId"); + relation.setReceiverAccount("2088000000000001"); + relation.setReceiverName("平台支付宝账号"); + relation.setOutRequestNo("alirel_7003"); + relation.setStatus(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode()); + relation.setDelFlag("0"); + return relation; + } + + /** 固定返回 Mock 支付宝客户端的关系服务测试实现。 */ + private static final class TestableThirdMchFzRelationServiceImpl extends ThirdMchFzRelationServiceImpl { + + /** 当前测试共享的支付宝 SDK 客户端 Mock。 */ + private final AlipayClient testAlipayClient; + + /** + * 创建可替换支付宝客户端的关系服务。 + * + * @param relationMapper 关系事实 Mapper Mock + * @param taskEnqueuer 关系绑定任务写入器 Mock + * @param profitsharingService 微信分账 SDK Mock + * @param appPlusPayConfig 平台支付配置 + * @param remoteMchService 商户内部接口 Mock + * @param testAlipayClient 支付宝 SDK 客户端 Mock + */ + private TestableThirdMchFzRelationServiceImpl(ThirdMchFzRelationMapper relationMapper, + ThirdPaymentTaskEnqueuer taskEnqueuer, + ProfitsharingService profitsharingService, + AppPlusPayConfig appPlusPayConfig, + RemoteMchService remoteMchService, + AlipayClient testAlipayClient) { + super(relationMapper, taskEnqueuer, profitsharingService, appPlusPayConfig, remoteMchService); + this.testAlipayClient = testAlipayClient; + } + + /** 返回测试注入的支付宝 SDK 客户端,避免发起真实渠道请求。 */ + @Override + protected AlipayClient client(AppPlusPayConfig.AliPayConfig config) { + return testAlipayClient; + } + } +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserDeptInterface.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserDeptInterface.java index fa62123c..9a546563 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserDeptInterface.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserDeptInterface.java @@ -4,9 +4,10 @@ package com.bsd.pjcloud.admin.api.vo.base; * 用户及其所属部门的通用数据契约。 * *

历史业务模型使用 {@code userMobile}、{@code userAvatar}、{@code userDeptName} 等字段, - * 标准别名用于直接适配管理端 {@code UserInfoCell} 组件,同时通过默认方法保持已有实现兼容。

+ * 标准别名用于直接适配管理端 {@code UserInfoCell} 组件,同时通过默认方法保持已有实现兼容。 + * 本接口继承基础用户契约,使实现类可以直接用于只要求用户主资料的通用装配方法。

*/ -public interface BaseUserDeptInterface { +public interface BaseUserDeptInterface extends BaseUserInterface, BaseUserPhoneInterface { /** * 获取用户主键。 * @@ -114,6 +115,7 @@ public interface BaseUserDeptInterface { * * @return 用户手机号;未绑定时为空 */ + @Override default String getPhone() { return getUserMobile(); } @@ -123,6 +125,7 @@ public interface BaseUserDeptInterface { * * @param phone 用户手机号,未绑定时可为空 */ + @Override default void setPhone(String phone) { setUserMobile(phone); } diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserIdentityInterface.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserIdentityInterface.java index b470cb47..cfd87c97 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserIdentityInterface.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserIdentityInterface.java @@ -9,9 +9,10 @@ import java.util.List; * 管理端用户身份信息组件使用的通用数据契约。 * *

字段与 {@code UserIdentityInfo} 组件的输入保持一致,业务 VO 实现该接口后可直接承载 - * UPMS 批量查询得到的身份类型、学生资料和老师资料。

+ * UPMS 批量查询得到的身份类型、学生资料和老师资料。标准手机号方法继承自 + * {@link BaseUserPhoneInterface},避免与用户部门接口的历史字段默认适配产生继承冲突。

*/ -public interface BaseUserIdentityInterface { +public interface BaseUserIdentityInterface extends BaseUserPhoneInterface { /** * 获取用户主键,用于批量查询和回填身份资料。 @@ -27,20 +28,6 @@ public interface BaseUserIdentityInterface { */ void setUserId(Long userId); - /** - * 获取默认用户身份展示的手机号。 - * - * @return 用户绑定手机号;未绑定时可为空 - */ - String getPhone(); - - /** - * 设置默认用户身份展示的手机号。 - * - * @param phone 用户绑定手机号,未绑定时可为空 - */ - void setPhone(String phone); - /** * 获取用户拥有的身份协议编码。 * diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserPhoneInterface.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserPhoneInterface.java new file mode 100644 index 00000000..56b034e7 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserPhoneInterface.java @@ -0,0 +1,24 @@ +package com.bsd.pjcloud.admin.api.vo.base; + +/** + * 用户标准手机号的公共数据契约。 + * + *

用户部门与用户身份接口都会使用标准手机号。将方法声明收敛到共同父接口,可让部门接口继续 + * 提供历史字段适配的默认实现,同时避免两个无关接口组成交叉类型时产生默认方法与抽象方法冲突。

+ */ +public interface BaseUserPhoneInterface { + + /** + * 获取用户标准手机号。 + * + * @return 用户绑定手机号;未绑定或尚未补充用户资料时可为空 + */ + String getPhone(); + + /** + * 设置用户标准手机号。 + * + * @param phone 用户绑定手机号;未绑定或需要清空展示值时可为空 + */ + void setPhone(String phone); +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletCodeUseDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletCodeUseDto.java index 15ef3972..f74a5a41 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletCodeUseDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletCodeUseDto.java @@ -1,13 +1,30 @@ package com.bsd.pjcloud.wallet.api.dto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface; import com.bsd.pjcloud.wallet.api.entity.WalletCodeUseEntity; import lombok.Data; import java.util.List; +/** 二维码使用记录查询条件,支持按申请用户及其身份资料筛选。 */ @Data -public class WalletCodeUseDto extends WalletCodeUseEntity { +public class WalletCodeUseDto extends WalletCodeUseEntity implements BaseUserIdentityQueryInterface { + /** 身份预筛选后用于二维码记录查询的用户主键集合。 */ private List userIds; + /** UPMS 用户身份协议编码集合;为空时不限制身份。 */ + private List identityTypes; + + /** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */ + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */ + private String hasFace; + } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletCodeUseVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletCodeUseVo.java index f3a2c34c..67c734c5 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletCodeUseVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletCodeUseVo.java @@ -1,19 +1,47 @@ package com.bsd.pjcloud.wallet.api.vo; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface; import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface; import com.bsd.pjcloud.wallet.api.entity.WalletCodeUseEntity; import lombok.Data; -@Data -public class WalletCodeUseVo extends WalletCodeUseEntity implements BaseUserInterface { +import java.util.List; - /** - * 用户相关 - */ +/** 二维码使用记录展示对象,包含申请用户主资料、部门和身份信息。 */ +@Data +public class WalletCodeUseVo extends WalletCodeUseEntity implements BaseUserInterface, BaseUserDeptInterface, BaseUserIdentityInterface { + + /** 申请用户姓名,保留历史接口字段名。 */ private String username; + /** 申请用户手机号,保留历史接口字段名。 */ private String userMobile; + /** 申请用户头像地址,保留历史接口字段名。 */ private String userAvatar; + /** 用户标准手机号字段,供统一身份组件展示。 */ + private String phone; + + /** 用户当前所属部门主键,由 UPMS 用户资料回填。 */ + private Long userDeptId; + + /** 用户当前所属部门名称,由 UPMS 用户资料回填。 */ + private String userDeptName; + + /** 用户所属部门展示名称,供统一用户信息组件读取。 */ + private String deptName; + + /** 用户拥有的 UPMS 身份协议编码集合。 */ + private List identityTypes; + + /** 用户的学生身份扩展资料;无学生身份时为空。 */ + private SysUserStudentDto studentInfo; + + /** 用户的老师身份扩展资料;无老师身份时为空。 */ + private SysUserTeacherDto teacherInfo; + } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletManageVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletManageVo.java index c84ed329..40131385 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletManageVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletManageVo.java @@ -2,6 +2,7 @@ package com.bsd.pjcloud.wallet.api.vo; import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; +import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface; import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface; import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface; import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; @@ -13,7 +14,7 @@ import java.util.List; /** 钱包管理端展示对象,包含公共用户主资料、部门及身份信息。 */ @Data -public class WalletManageVo extends WalletManageEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseWalletTypeInterface { +public class WalletManageVo extends WalletManageEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface, BaseWalletTypeInterface { /** * 钱包类型 diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/transaction/WalletTransactionVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/transaction/WalletTransactionVo.java index a5c6beb6..6f95628f 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/transaction/WalletTransactionVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/transaction/WalletTransactionVo.java @@ -2,6 +2,7 @@ package com.bsd.pjcloud.wallet.api.vo.transaction; import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; +import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface; import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface; import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface; import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity; @@ -14,7 +15,7 @@ import java.util.List; /** 钱包流水管理端展示对象,包含公共用户主资料、部门及身份信息。 */ @Data -public class WalletTransactionVo extends WalletTransactionEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseWalletTypeInterface, BaseWalletInterface { +public class WalletTransactionVo extends WalletTransactionEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface, BaseWalletTypeInterface, BaseWalletInterface { /** * 字符串金额用于导出 diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletCodeUseServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletCodeUseServiceImpl.java index 4edfac2e..6cba878d 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletCodeUseServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletCodeUseServiceImpl.java @@ -9,13 +9,10 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bsd.pjcloud.admin.api.dto.UserInfo; -import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.feign.RemoteFileService; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; -import com.bsd.pjcloud.admin.api.utils.DataUtil; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.util.BeanUtils; -import com.bsd.pjcloud.common.core.util.CollectionUtils; import com.bsd.pjcloud.common.core.util.QRCodeImageGenerator; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.hard.api.dto.code.QRCodeExtraDto; @@ -29,6 +26,7 @@ import com.bsd.pjcloud.wallet.api.vo.WalletCodeUseVo; import com.bsd.pjcloud.wallet.api.vo.WalletSettingVo; import com.bsd.pjcloud.wallet.mapper.WalletCodeUseMapper; import com.bsd.pjcloud.wallet.service.*; +import com.bsd.pjcloud.wallet.support.WalletUserIdentitySupport; 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.code.QRCodeDto; @@ -47,7 +45,6 @@ import org.springframework.web.multipart.MultipartFile; import java.io.ByteArrayInputStream; import java.time.LocalDateTime; import java.time.ZoneId; -import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -75,6 +72,8 @@ public class WalletCodeUseServiceImpl extends ServiceImpl pageJoin(Page page, WalletCodeUseDto dto) { + prepareIdentityQuery(dto); Page list = baseMapper.pageJoin(page, dto); - if (ObjectUtil.isNotEmpty(list.getRecords())) { - // 用户 - List userIds = list.getRecords().stream().map(WalletCodeUseVo::getUserId).distinct().toList(); - R> userList = userService.getUserById(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserList(list.getRecords(), CollectionUtils.convertMap(userList.getData(), SysUser::getUserId)); - } - } + userIdentitySupport.fillUserInfo(list.getRecords(), true); return list; } + /** + * 将身份筛选条件转换为最终用户主键集合,复用 Mapper 已有的 {@code userIds} 过滤能力。 + * + * @param dto 二维码使用记录查询条件;处理后身份字段被最终用户主键集合替代 + */ + private void prepareIdentityQuery(WalletCodeUseDto dto) { + if (!userIdentitySupport.hasIdentityQuery(dto)) { + return; + } + dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds())); + dto.setUserId(null); + dto.setIdentityTypes(null); + dto.setStudentInfo(null); + dto.setTeacherInfo(null); + dto.setHasFace(null); + } + @Override // @GlobalTransactional @Transactional(rollbackFor = Exception.class) diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java index 2f78f9c8..433745e3 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java @@ -371,7 +371,7 @@ public class WalletManageServiceImpl extends ServiceImpl scopeQbList(WalletManageBalanceDto dto, DataScope dataScope) { WalletManageDto walletDto = BeanUtils.toBean(dto, WalletManageDto.class); prepareIdentityQuery(walletDto); - BeanUtils.copyProperties(walletDto, dto); + BeanUtil.copyProperties(walletDto, dto); List walletList = baseMapper.scopeList(walletDto, dataScope); if (ObjectUtil.isEmpty(walletList)) return walletList; walletList.forEach(v -> { From 14c2acb6c2bcfb0dd8554790f7d71a7737d9e19a Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Wed, 26 Aug 2026 13:10:07 +0800 Subject: [PATCH 03/31] =?UTF-8?q?feat:=E6=94=AF=E4=BB=98=E5=AE=9D=E5=88=86?= =?UTF-8?q?=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/20260805_add_third_aggregate_pay.sql | 37 - db/20260824_add_third_payment_task.sql | 153 --- db/20260825_add_third_mch_fz_relation.sql | 111 -- db/20260825_third_payment_schema.sql | 362 ++++++ db/800pjcloudx_wallet.sql | 3 - .../datascope/DataScopeInnerInterceptor.java | 13 +- .../refund/HardAutoRefundTriggerTypeEnum.java | 7 +- .../api/entity/order/HardOrderEntity.java | 6 +- .../HardExpiredRefundRescanResultVo.java | 36 + .../order/HardAutoRefundTaskController.java | 61 +- .../order/HardCanteenTotalController.java | 61 + .../controller/order/HardOrderController.java | 162 +-- .../order/HardAutoRefundTaskMapper.java | 10 + .../service/HardApptReservationService.java | 2 +- .../service/HardAutoRefundTaskService.java | 10 + .../hard/service/HardOrderExportService.java | 30 + .../impl/HardApptReservationServiceImpl.java | 32 +- .../order/HardAutoRefundTaskServiceImpl.java | 296 ++++- .../HardCanteenApptTotalServiceImpl.java | 6 +- .../order/HardCanteenTotalServiceImpl.java | 219 ++-- .../order/HardOrderExportServiceImpl.java | 1060 ++++++++++++++++ .../impl/order/HardOrderServiceImpl.java | 18 +- .../impl/order/HardOrderTotalServiceImpl.java | 6 +- .../mapper/order/HardAutoRefundTaskMapper.xml | 34 + .../resources/mapper/order/HardCarMapper.xml | 20 +- .../mapper/order/HardOrderMapper.xml | 16 +- .../HardApptReservationServiceImplTest.java | 22 +- .../HardAutoRefundTaskServiceImplTest.java | 67 +- .../HardOrderServiceImplVerificationTest.java | 24 +- .../dto/pay/ThirdMchFzRelationSaveDto.java | 59 + .../entity/pay/ThirdMchFzRelationEntity.java | 8 +- .../vo/pay/ThirdMchFzRelationVo.java | 51 + .../thirdparty/config/AppPlusPayConfig.java | 25 +- .../pay/ThirdMchFzRelationController.java | 103 ++ .../service/ThirdMchFzRelationService.java | 64 +- .../ThirdAggregateAliPayServiceImpl.java | 21 +- .../ThirdAggregateOrderPayServiceImpl.java | 31 +- .../ThirdAggregateWechatPayServiceImpl.java | 17 +- .../ThirdAppPlusAliPayServiceImpl.java | 346 +++--- .../impl/appplus/ThirdAppPlusServiceImpl.java | 66 +- ...ThirdAppPlusWechatMicropayServiceImpl.java | 18 +- ...hirdAppPlusWechatNativePayServiceImpl.java | 18 +- .../ThirdAppPlusWechatServiceImpl.java | 22 +- .../fz/ThirdAppPlusWechatFzServiceImpl.java | 33 +- .../ThirdAggregatePaySessionServiceImpl.java | 17 +- .../pay/ThirdMchFzRelationServiceImpl.java | 1088 ++++++++++++----- .../impl/pay/ThirdPayOrderServiceImpl.java | 21 +- ...ThirdPaymentTaskMapperSqlContractTest.java | 4 +- .../ThirdAggregateAliPayServiceImplTest.java | 15 +- .../ThirdAppPlusAliPayServiceImplTest.java | 39 +- ...irdAppPlusServiceImplOrderCodePayTest.java | 1 - .../ThirdAppPlusWechatFzServiceImplTest.java | 21 +- ...pPlusWechatServiceImplPayCallbackTest.java | 5 + ...irdAppPlusWechatServiceImplRefundTest.java | 44 +- .../ThirdMchFzRelationServiceImplTest.java | 313 +++-- .../admin/api/feign/RemoteUserService.java | 3 +- .../admin/controller/SysUserController.java | 53 +- .../service/impl/SysUserServiceImpl.java | 9 +- .../api/entity/mch/MchPaySettingEntity.java | 14 +- .../impl/mch/MchPaySettingServiceImpl.java | 57 +- .../mapper/mch/MchPaySettingMapper.xml | 3 +- .../mch/MchPaySettingServiceImplTest.java | 25 +- 62 files changed, 4095 insertions(+), 1403 deletions(-) delete mode 100644 db/20260805_add_third_aggregate_pay.sql delete mode 100644 db/20260824_add_third_payment_task.sql delete mode 100644 db/20260825_add_third_mch_fz_relation.sql create mode 100644 db/20260825_third_payment_schema.sql create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardExpiredRefundRescanResultVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderExportService.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdMchFzRelationSaveDto.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdMchFzRelationVo.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdMchFzRelationController.java diff --git a/db/20260805_add_third_aggregate_pay.sql b/db/20260805_add_third_aggregate_pay.sql deleted file mode 100644 index 734e27f8..00000000 --- a/db/20260805_add_third_aggregate_pay.sql +++ /dev/null @@ -1,37 +0,0 @@ --- 支付宝与微信聚合订单码支付。 --- 聚合会话与实际第三方支付订单分表保存,避免改变已被多个调用方使用的 third_pay_order 契约。 --- 微信统一使用服务商平台公众号配置和各商户既有 sub_mchid,不新增逐商户公众号配置表。 - -CREATE TABLE `third_aggregate_pay_session` -( - `id` bigint NOT NULL COMMENT '聚合会话主键', - `aggregate_no` varchar(64) NOT NULL COMMENT '聚合支付会话唯一编号', - `access_token_hash` char(64) NOT NULL COMMENT '收银URL随机访问令牌SHA-256摘要', - `access_token_ciphertext` varchar(512) NOT NULL COMMENT '收银URL随机访问令牌Jasypt密文,仅用于幂等复用', - `biz_type` varchar(32) NOT NULL COMMENT '支付业务类型编码,首版为YXFJ_PAY对应编码', - `biz_id` varchar(128) NOT NULL COMMENT '调用方业务订单唯一标识', - `user_id` bigint NOT NULL COMMENT '付款用户ID,匿名消费机订单为-1', - `mch_id` bigint NOT NULL COMMENT '收款商户ID', - `price` int NOT NULL COMMENT '应付金额,单位为人民币分', - `status` varchar(32) NOT NULL COMMENT '聚合会话状态编码', - `selected_platform` varchar(32) DEFAULT NULL COMMENT '首个有效扫码选定的支付平台编码', - `pay_no` varchar(128) DEFAULT NULL COMMENT '绑定的third_pay_order.pay_no', - `payer_identity_hash` char(64) DEFAULT NULL COMMENT '支付宝用户ID或微信服务商公众号sp_openid的SHA-256摘要', - `expire_time` datetime NOT NULL COMMENT '聚合二维码与第三方子订单服务端失效时间', - `resolved_time` datetime DEFAULT NULL COMMENT '会话进入成功、关闭、过期或创建失败终态的时间', - `create_by` varchar(64) DEFAULT NULL COMMENT '创建人', - `create_time` datetime NOT NULL COMMENT '创建时间', - `update_by` varchar(64) DEFAULT NULL COMMENT '修改人', - `update_time` datetime NOT NULL COMMENT '修改时间', - `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除', - `tenant_id` bigint NOT NULL COMMENT '租户ID', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_third_aggregate_pay_no` (`tenant_id`, `aggregate_no`), - UNIQUE KEY `uk_third_aggregate_access_token` (`tenant_id`, `access_token_hash`), - UNIQUE KEY `uk_third_aggregate_child_pay_no` (`tenant_id`, `pay_no`), - KEY `idx_third_aggregate_biz` (`tenant_id`, `biz_type`, `biz_id`, `create_time`), - KEY `idx_third_aggregate_job` (`status`, `expire_time`) -) ENGINE = InnoDB - DEFAULT CHARSET = utf8mb4 - COLLATE = utf8mb4_general_ci - COMMENT = '支付宝与微信聚合订单码支付会话'; diff --git a/db/20260824_add_third_payment_task.sql b/db/20260824_add_third_payment_task.sql deleted file mode 100644 index 31468234..00000000 --- a/db/20260824_add_third_payment_task.sql +++ /dev/null @@ -1,153 +0,0 @@ --- thirdparty 支付任务事实表与执行尝试表。 --- Quartz 是唯一周期触发入口;任务表只保存可恢复事实、租约和幂等状态。 -CREATE TABLE IF NOT EXISTS `third_payment_task` -( - `id` bigint NOT NULL COMMENT '任务主键', - `tenant_id` bigint NOT NULL COMMENT '租户ID', - `task_type` varchar(48) NOT NULL COMMENT '任务类型编码', - `task_key` varchar(192) NOT NULL COMMENT '租户内稳定幂等任务键', - `pay_order_id` bigint DEFAULT NULL COMMENT 'third_pay_order主键', - `fz_order_id` bigint DEFAULT NULL COMMENT 'third_fz_order主键', - `aggregate_session_id` bigint DEFAULT NULL COMMENT 'third_aggregate_pay_session主键', - `pay_no` varchar(128) DEFAULT NULL COMMENT '平台支付单号', - `refund_no` varchar(128) DEFAULT NULL COMMENT '平台退款单号', - `fz_no` varchar(128) DEFAULT NULL COMMENT '平台分账单号', - `aggregate_no` varchar(128) DEFAULT NULL COMMENT '聚合会话单号', - `status` varchar(16) NOT NULL COMMENT '任务状态:READY/PROCESSING/RETRY/SUCCESS/FAILED/ALERT/CANCELLED', - `next_run_time` datetime NOT NULL COMMENT '下一次可执行时间', - `priority` int NOT NULL DEFAULT 0 COMMENT '任务优先级,数值越大越优先', - `lease_owner` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识', - `lease_token` varchar(64) DEFAULT NULL COMMENT '本次执行租约令牌', - `lease_expire_time` datetime DEFAULT NULL COMMENT '租约失效时间', - `attempt_count` int NOT NULL DEFAULT 0 COMMENT '累计执行次数', - `last_external_status` varchar(64) DEFAULT NULL COMMENT '最近一次渠道状态', - `last_error_code` varchar(128) DEFAULT NULL COMMENT '最近一次错误码', - `last_error_message` varchar(1024) DEFAULT NULL COMMENT '最近一次错误摘要', - `finished_time` datetime DEFAULT NULL COMMENT '任务终态时间', - `create_by` varchar(64) DEFAULT NULL COMMENT '创建人', - `create_time` datetime NOT NULL COMMENT '创建时间', - `update_by` varchar(64) DEFAULT NULL COMMENT '修改人', - `update_time` datetime NOT NULL COMMENT '更新时间', - `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_third_payment_task_tenant_key` (`tenant_id`, `task_key`), - KEY `idx_third_payment_task_due` (`task_type`, `status`, `next_run_time`, `priority`, `id`), - KEY `idx_third_payment_task_lease` (`task_type`, `status`, `lease_expire_time`), - KEY `idx_third_payment_task_pay_no` (`tenant_id`, `pay_no`), - KEY `idx_third_payment_task_fz_no` (`tenant_id`, `fz_no`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务事实'; - -CREATE TABLE IF NOT EXISTS `third_payment_task_attempt` -( - `id` bigint NOT NULL COMMENT '尝试记录主键', - `task_id` bigint NOT NULL COMMENT '任务主键', - `tenant_id` bigint NOT NULL COMMENT '租户ID', - `attempt_no` int NOT NULL COMMENT '任务内尝试序号', - `trigger_source` varchar(32) NOT NULL COMMENT '触发来源:QUARTZ/CALLBACK/SYNC/MANUAL', - `worker_id` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识', - `lease_token` varchar(64) DEFAULT NULL COMMENT '执行租约令牌', - `request_snapshot` text DEFAULT NULL COMMENT '脱敏请求快照', - `response_snapshot` text DEFAULT NULL COMMENT '脱敏响应快照', - `external_status` varchar(64) DEFAULT NULL COMMENT '渠道状态', - `error_code` varchar(128) DEFAULT NULL COMMENT '错误码', - `error_message` varchar(1024) DEFAULT NULL COMMENT '错误摘要', - `started_time` datetime NOT NULL COMMENT '开始时间', - `finished_time` datetime DEFAULT NULL COMMENT '结束时间', - PRIMARY KEY (`id`), - KEY `idx_third_payment_attempt_task` (`task_id`, `attempt_no`), - KEY `idx_third_payment_attempt_tenant_time` (`tenant_id`, `started_time`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务执行尝试'; - --- Quartz 会按业务事实持续物化任务。百万级订单量下,如果业务表只有主键,每个固定触发器都会退化为全表扫描。 --- 以下索引只覆盖实际扫描谓词和租户内业务号关联;使用 information_schema 判断保证迁移可以重复执行。 -SET @index_ddl = IF( - EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() - AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_task_scan'), - 'SELECT ''idx_third_pay_order_task_scan already exists'' AS message', - 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_task_scan` (`status`, `del_flag`, `pay_platform_type`, `update_time`, `id`)' -); -PREPARE index_stmt FROM @index_ddl; -EXECUTE index_stmt; -DEALLOCATE PREPARE index_stmt; - -SET @index_ddl = IF( - EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() - AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_fz_scan'), - 'SELECT ''idx_third_pay_order_fz_scan already exists'' AS message', - 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_fz_scan` (`status`, `enable_fz`, `del_flag`, `pay_time`, `id`)' -); -PREPARE index_stmt FROM @index_ddl; -EXECUTE index_stmt; -DEALLOCATE PREPARE index_stmt; - -SET @index_ddl = IF( - EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() - AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_callback_scan'), - 'SELECT ''idx_third_pay_order_callback_scan already exists'' AS message', - 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_callback_scan` (`status`, `is_biz_callback`, `del_flag`, `update_time`, `id`)' -); -PREPARE index_stmt FROM @index_ddl; -EXECUTE index_stmt; -DEALLOCATE PREPARE index_stmt; - -SET @index_ddl = IF( - EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() - AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_close_scan'), - 'SELECT ''idx_third_pay_order_close_scan already exists'' AS message', - 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_close_scan` (`status`, `biz_type`, `del_flag`, `create_time`, `id`)' -); -PREPARE index_stmt FROM @index_ddl; -EXECUTE index_stmt; -DEALLOCATE PREPARE index_stmt; - -SET @index_ddl = IF( - EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() - AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_tenant_pay_no'), - 'SELECT ''idx_third_pay_order_tenant_pay_no already exists'' AS message', - 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)' -); -PREPARE index_stmt FROM @index_ddl; -EXECUTE index_stmt; -DEALLOCATE PREPARE index_stmt; - -SET @index_ddl = IF( - EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() - AND table_name = 'third_fz_order' AND index_name = 'idx_third_fz_order_task_scan'), - 'SELECT ''idx_third_fz_order_task_scan already exists'' AS message', - 'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_task_scan` (`status`, `del_flag`, `update_time`, `id`)' -); -PREPARE index_stmt FROM @index_ddl; -EXECUTE index_stmt; -DEALLOCATE PREPARE index_stmt; - -SET @index_ddl = IF( - EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() - AND table_name = 'third_fz_order' AND index_name = 'idx_third_fz_order_tenant_pay_no'), - 'SELECT ''idx_third_fz_order_tenant_pay_no already exists'' AS message', - 'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)' -); -PREPARE index_stmt FROM @index_ddl; -EXECUTE index_stmt; -DEALLOCATE PREPARE index_stmt; - -SET @index_ddl = IF( - EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() - AND table_name = 'third_aggregate_pay_session' AND index_name = 'idx_third_aggregate_task_scan'), - 'SELECT ''idx_third_aggregate_task_scan already exists'' AS message', - 'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_task_scan` (`status`, `del_flag`, `update_time`, `id`)' -); -PREPARE index_stmt FROM @index_ddl; -EXECUTE index_stmt; -DEALLOCATE PREPARE index_stmt; - -SET @index_ddl = IF( - EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() - AND table_name = 'third_aggregate_pay_session' AND index_name = 'idx_third_aggregate_close_scan'), - 'SELECT ''idx_third_aggregate_close_scan already exists'' AS message', - 'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_close_scan` (`status`, `del_flag`, `create_time`, `id`)' -); -PREPARE index_stmt FROM @index_ddl; -EXECUTE index_stmt; -DEALLOCATE PREPARE index_stmt; - --- 生产执行建议:低峰期使用在线 DDL 能力建索引,完成后 ANALYZE TABLE 并用真实状态分布执行 EXPLAIN ANALYZE。 diff --git a/db/20260825_add_third_mch_fz_relation.sql b/db/20260825_add_third_mch_fz_relation.sql deleted file mode 100644 index 6ffc39cc..00000000 --- a/db/20260825_add_third_mch_fz_relation.sql +++ /dev/null @@ -1,111 +0,0 @@ -USE `pjcloudx_thirdparty`; - --- 商户默认平台分佣关系。微信关系由 sub_mchid 区分,支付宝关系由调用应用、授权商户和接收账号共同确定。 --- 表中只保存渠道标识和脱敏调用快照,不保存私钥、公钥或完整 app_auth_token。 -CREATE TABLE IF NOT EXISTS `third_mch_fz_relation` -( - `id` bigint NOT NULL COMMENT '关系主键,应用侧雪花算法生成', - `tenant_id` bigint NOT NULL COMMENT '租户ID,与 mch_manage 所属租户一致', - `mch_id` bigint NOT NULL COMMENT 'mch_manage 商户主键', - `pay_setting_id` bigint NOT NULL COMMENT 'mch_pay_setting 支付配置主键', - `platform_type` varchar(32) NOT NULL COMMENT '支付平台类型:10微信、20支付宝', - `relation_scene` varchar(64) NOT NULL COMMENT '本地关系场景,默认平台分佣为 DEFAULT_PLATFORM_COMMISSION', - `channel_app_id` varchar(64) NOT NULL COMMENT '微信服务商AppID或支付宝平台AppID快照', - `channel_sub_mch_id` varchar(64) NOT NULL COMMENT '微信子商户号或支付宝直付通smid/ip_role_id', - `account_key` varchar(64) DEFAULT NULL COMMENT '支付宝命名平台账号键;默认账号及微信为空', - `receiver_type` varchar(32) NOT NULL COMMENT '渠道接收方类型,如MERCHANT_ID、userId、loginName', - `receiver_account` varchar(128) NOT NULL COMMENT '平台分佣接收账号;管理端展示时必须脱敏', - `receiver_name` varchar(128) DEFAULT NULL COMMENT '平台分佣接收方名称快照', - `channel_relation_type` varchar(64) DEFAULT NULL COMMENT '渠道关系类型;微信默认SERVICE_PROVIDER,支付宝为空', - `out_request_no` varchar(64) NOT NULL COMMENT '支付宝绑定稳定幂等请求号;微信作为本地稳定审计号', - `status` varchar(32) NOT NULL COMMENT 'PENDING_BIND、BINDING、BOUND、BIND_FAILED或SUPERSEDED', - `is_default` char(1) NOT NULL DEFAULT '1' COMMENT '是否为当前支付配置的默认平台分佣关系:0否、1是', - `bind_req` text DEFAULT NULL COMMENT '脱敏后的关系绑定请求快照', - `bind_res` text DEFAULT NULL COMMENT '渠道关系绑定或查询响应快照', - `bind_error` varchar(1000) DEFAULT NULL COMMENT '最近一次绑定错误摘要,不含凭据', - `last_external_status` varchar(128) DEFAULT NULL COMMENT '最近一次渠道结果码或外部状态', - `last_query_time` datetime DEFAULT NULL COMMENT '最近一次关系绑定或查询时间', - `bound_time` datetime DEFAULT NULL COMMENT '渠道明确确认关系已绑定的时间', - `create_by` varchar(32) DEFAULT NULL COMMENT '创建人', - `create_time` datetime DEFAULT NULL COMMENT '创建时间', - `update_by` varchar(32) DEFAULT NULL COMMENT '更新人', - `update_time` datetime DEFAULT NULL COMMENT '更新时间', - `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记:0正常、1删除', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_third_mch_fz_relation_identity` - (`tenant_id`, `pay_setting_id`, `platform_type`, `channel_app_id`, `channel_sub_mch_id`, - `receiver_type`, `receiver_account`, `relation_scene`), - KEY `idx_third_mch_fz_relation_default` - (`tenant_id`, `mch_id`, `platform_type`, `is_default`, `status`, `del_flag`), - KEY `idx_third_mch_fz_relation_task` - (`status`, `is_default`, `update_time`, `id`) -) ENGINE = InnoDB - DEFAULT CHARSET = utf8mb4 - COLLATE = utf8mb4_general_ci - COMMENT ='商户第三方支付分账关系'; - --- 支付任务通过结构化主键定位关系事实,禁止从 task_key 字符串反解析业务主键。 -SET @relation_task_column_exists = ( - SELECT COUNT(1) - FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'third_payment_task' - AND COLUMN_NAME = 'fz_relation_id' -); -SET @relation_task_column_sql = IF( - @relation_task_column_exists = 0, - 'ALTER TABLE `third_payment_task` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''商户分账关系主键'' AFTER `fz_order_id`', - 'SELECT 1' -); -PREPARE relation_task_column_stmt FROM @relation_task_column_sql; -EXECUTE relation_task_column_stmt; -DEALLOCATE PREPARE relation_task_column_stmt; - -SET @relation_task_index_exists = ( - SELECT COUNT(1) - FROM information_schema.STATISTICS - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'third_payment_task' - AND INDEX_NAME = 'idx_third_payment_task_relation' -); -SET @relation_task_index_sql = IF( - @relation_task_index_exists = 0, - 'ALTER TABLE `third_payment_task` ADD INDEX `idx_third_payment_task_relation` (`tenant_id`, `fz_relation_id`)', - 'SELECT 1' -); -PREPARE relation_task_index_stmt FROM @relation_task_index_sql; -EXECUTE relation_task_index_stmt; -DEALLOCATE PREPARE relation_task_index_stmt; - --- 支付订单固定引用创建时使用的已绑定关系,后续分账不跟随平台接收账号配置漂移。 -SET @pay_relation_column_exists = ( - SELECT COUNT(1) - FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'third_pay_order' - AND COLUMN_NAME = 'fz_relation_id' -); -SET @pay_relation_column_sql = IF( - @pay_relation_column_exists = 0, - 'ALTER TABLE `third_pay_order` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''支付创建时绑定的平台分佣关系主键'' AFTER `mch_pay_setting_json`', - 'SELECT 1' -); -PREPARE pay_relation_column_stmt FROM @pay_relation_column_sql; -EXECUTE pay_relation_column_stmt; -DEALLOCATE PREPARE pay_relation_column_stmt; - -SET @pay_relation_index_exists = ( - SELECT COUNT(1) - FROM information_schema.STATISTICS - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'third_pay_order' - AND INDEX_NAME = 'idx_third_pay_order_fz_relation' -); -SET @pay_relation_index_sql = IF( - @pay_relation_index_exists = 0, - 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_fz_relation` (`tenant_id`, `fz_relation_id`)', - 'SELECT 1' -); -PREPARE pay_relation_index_stmt FROM @pay_relation_index_sql; -EXECUTE pay_relation_index_stmt; -DEALLOCATE PREPARE pay_relation_index_stmt; diff --git a/db/20260825_third_payment_schema.sql b/db/20260825_third_payment_schema.sql new file mode 100644 index 00000000..9841ed27 --- /dev/null +++ b/db/20260825_third_payment_schema.sql @@ -0,0 +1,362 @@ +-- thirdparty 支付、聚合支付、任务补偿和商户分账关系统一迁移脚本。 +-- 线上只执行本文件;脚本可重复执行,兼容全新数据库和已经执行部分旧迁移的数据库。 +-- 本脚本不删除支付任务、订单、分账关系或历史配置数据;仅在迁移核对通过后清理旧配置字段。 + +USE `pjcloudx_thirdparty`; + +-- 一、聚合订单码支付会话 +CREATE TABLE IF NOT EXISTS `third_aggregate_pay_session` +( + `id` bigint NOT NULL COMMENT '聚合会话主键', + `aggregate_no` varchar(64) NOT NULL COMMENT '聚合支付会话唯一编号', + `access_token_hash` char(64) NOT NULL COMMENT '收银URL随机访问令牌SHA-256摘要', + `access_token_ciphertext` varchar(512) NOT NULL COMMENT '收银URL随机访问令牌Jasypt密文,仅用于幂等复用', + `biz_type` varchar(32) NOT NULL COMMENT '支付业务类型编码,首版为YXFJ_PAY对应编码', + `biz_id` varchar(128) NOT NULL COMMENT '调用方业务订单唯一标识', + `user_id` bigint NOT NULL COMMENT '付款用户ID,匿名消费机订单为-1', + `mch_id` bigint NOT NULL COMMENT '收款商户ID', + `price` int NOT NULL COMMENT '应付金额,单位为人民币分', + `status` varchar(32) NOT NULL COMMENT '聚合会话状态编码', + `selected_platform` varchar(32) DEFAULT NULL COMMENT '首个有效扫码选定的支付平台编码', + `pay_no` varchar(128) DEFAULT NULL COMMENT '绑定的third_pay_order.pay_no', + `payer_identity_hash` char(64) DEFAULT NULL COMMENT '支付宝用户ID或微信服务商公众号sp_openid的SHA-256摘要', + `expire_time` datetime NOT NULL COMMENT '聚合二维码与第三方子订单服务端失效时间', + `resolved_time` datetime DEFAULT NULL COMMENT '会话进入成功、关闭、过期或创建失败终态的时间', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '修改人', + `update_time` datetime NOT NULL COMMENT '修改时间', + `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除', + `tenant_id` bigint NOT NULL COMMENT '租户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_third_aggregate_pay_no` (`tenant_id`, `aggregate_no`), + UNIQUE KEY `uk_third_aggregate_access_token` (`tenant_id`, `access_token_hash`), + UNIQUE KEY `uk_third_aggregate_child_pay_no` (`tenant_id`, `pay_no`), + KEY `idx_third_aggregate_biz` (`tenant_id`, `biz_type`, `biz_id`, `create_time`), + KEY `idx_third_aggregate_job` (`status`, `expire_time`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci + COMMENT = '支付宝与微信聚合订单码支付会话'; + +-- 二、Quartz 触发的可恢复支付任务和执行尝试 +CREATE TABLE IF NOT EXISTS `third_payment_task` +( + `id` bigint NOT NULL COMMENT '任务主键', + `tenant_id` bigint NOT NULL COMMENT '租户ID', + `task_type` varchar(48) NOT NULL COMMENT '任务类型编码', + `task_key` varchar(192) NOT NULL COMMENT '租户内稳定幂等任务键', + `pay_order_id` bigint DEFAULT NULL COMMENT 'third_pay_order主键', + `fz_order_id` bigint DEFAULT NULL COMMENT 'third_fz_order主键', + `fz_relation_id` bigint DEFAULT NULL COMMENT 'third_mch_fz_relation主键', + `aggregate_session_id` bigint DEFAULT NULL COMMENT 'third_aggregate_pay_session主键', + `pay_no` varchar(128) DEFAULT NULL COMMENT '平台支付单号', + `refund_no` varchar(128) DEFAULT NULL COMMENT '平台退款单号', + `fz_no` varchar(128) DEFAULT NULL COMMENT '平台分账单号', + `aggregate_no` varchar(128) DEFAULT NULL COMMENT '聚合会话单号', + `status` varchar(16) NOT NULL COMMENT '任务状态:READY/PROCESSING/RETRY/SUCCESS/FAILED/ALERT/CANCELLED', + `next_run_time` datetime NOT NULL COMMENT '下一次可执行时间', + `priority` int NOT NULL DEFAULT 0 COMMENT '任务优先级,数值越大越优先', + `lease_owner` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识', + `lease_token` varchar(64) DEFAULT NULL COMMENT '本次执行租约令牌', + `lease_expire_time` datetime DEFAULT NULL COMMENT '租约失效时间', + `attempt_count` int NOT NULL DEFAULT 0 COMMENT '累计执行次数', + `last_external_status` varchar(64) DEFAULT NULL COMMENT '最近一次渠道状态', + `last_error_code` varchar(128) DEFAULT NULL COMMENT '最近一次错误码', + `last_error_message` varchar(1024) DEFAULT NULL COMMENT '最近一次错误摘要', + `finished_time` datetime DEFAULT NULL COMMENT '任务终态时间', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '修改人', + `update_time` datetime NOT NULL COMMENT '更新时间', + `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_third_payment_task_tenant_key` (`tenant_id`, `task_key`), + KEY `idx_third_payment_task_due` (`task_type`, `status`, `next_run_time`, `priority`, `id`), + KEY `idx_third_payment_task_lease` (`task_type`, `status`, `lease_expire_time`), + KEY `idx_third_payment_task_pay_no` (`tenant_id`, `pay_no`), + KEY `idx_third_payment_task_fz_no` (`tenant_id`, `fz_no`), + KEY `idx_third_payment_task_relation` (`tenant_id`, `fz_relation_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务事实'; + +CREATE TABLE IF NOT EXISTS `third_payment_task_attempt` +( + `id` bigint NOT NULL COMMENT '尝试记录主键', + `task_id` bigint NOT NULL COMMENT '任务主键', + `tenant_id` bigint NOT NULL COMMENT '租户ID', + `attempt_no` int NOT NULL COMMENT '任务内尝试序号', + `trigger_source` varchar(32) NOT NULL COMMENT '触发来源:QUARTZ/CALLBACK/SYNC/MANUAL', + `worker_id` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识', + `lease_token` varchar(64) DEFAULT NULL COMMENT '执行租约令牌', + `request_snapshot` text DEFAULT NULL COMMENT '脱敏请求快照', + `response_snapshot` text DEFAULT NULL COMMENT '脱敏响应快照', + `external_status` varchar(64) DEFAULT NULL COMMENT '渠道状态', + `error_code` varchar(128) DEFAULT NULL COMMENT '错误码', + `error_message` varchar(1024) DEFAULT NULL COMMENT '错误摘要', + `started_time` datetime NOT NULL COMMENT '开始时间', + `finished_time` datetime DEFAULT NULL COMMENT '结束时间', + PRIMARY KEY (`id`), + KEY `idx_third_payment_attempt_task` (`task_id`, `attempt_no`), + KEY `idx_third_payment_attempt_tenant_time` (`tenant_id`, `started_time`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务执行尝试'; + +-- 三、商户支付分账关系(微信、支付宝共用) +CREATE TABLE IF NOT EXISTS `third_mch_fz_relation` +( + `id` bigint NOT NULL COMMENT '关系主键,应用侧雪花算法生成', + `tenant_id` bigint NOT NULL COMMENT '租户ID,与mch_manage所属租户一致', + `mch_id` bigint NOT NULL COMMENT 'mch_manage商户主键', + `pay_setting_id` bigint NOT NULL COMMENT 'mch_pay_setting支付配置主键', + `platform_type` varchar(32) NOT NULL COMMENT '支付平台类型:10微信、20支付宝', + `relation_scene` varchar(64) NOT NULL COMMENT '本地关系场景,默认平台分佣为DEFAULT_PLATFORM_COMMISSION', + `channel_app_id` varchar(64) NOT NULL COMMENT '微信服务商AppID或支付宝平台AppID快照', + `channel_sub_mch_id` varchar(64) NOT NULL COMMENT '微信子商户号或历史渠道子商户标识快照', + `account_key` varchar(64) DEFAULT NULL COMMENT '支付宝命名平台账号键;默认账号及微信为空', + `app_auth_token` varchar(512) DEFAULT NULL COMMENT '支付宝第三方代调用授权令牌;仅服务端渠道调用使用', + `receiver_type` varchar(32) NOT NULL COMMENT '渠道接收方类型,如MERCHANT_ID、userId、loginName', + `receiver_account` varchar(128) NOT NULL COMMENT '平台分佣接收账号;管理端展示时必须脱敏', + `receiver_name` varchar(128) DEFAULT NULL COMMENT '平台分佣接收方名称快照', + `fz_ratio` decimal(6,3) NOT NULL DEFAULT '0.000' COMMENT '平台抽佣比例,单位百分比,范围0到100,精确到0.001%', + `channel_relation_type` varchar(64) DEFAULT NULL COMMENT '渠道关系类型;微信默认SERVICE_PROVIDER,支付宝允许为空', + `out_request_no` varchar(64) NOT NULL COMMENT '支付宝绑定稳定幂等请求号;微信作为本地稳定审计号', + `status` varchar(32) NOT NULL COMMENT 'PENDING_BIND、BINDING、BOUND、BIND_FAILED或SUPERSEDED', + `is_default` char(1) NOT NULL DEFAULT '1' COMMENT '是否为当前支付配置默认关系:0否、1是', + `bind_req` text DEFAULT NULL COMMENT '脱敏后的关系绑定请求快照', + `bind_res` text DEFAULT NULL COMMENT '渠道关系绑定或查询响应快照', + `bind_error` varchar(1000) DEFAULT NULL COMMENT '最近一次绑定错误摘要,不含凭据', + `last_external_status` varchar(128) DEFAULT NULL COMMENT '最近一次渠道结果码或外部状态', + `last_query_time` datetime DEFAULT NULL COMMENT '最近一次关系绑定或查询时间', + `bound_time` datetime DEFAULT NULL COMMENT '渠道明确确认关系已绑定的时间', + `create_by` varchar(32) DEFAULT NULL COMMENT '创建人', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_by` varchar(32) DEFAULT NULL COMMENT '更新人', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记:0正常、1删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_third_mch_fz_relation_identity` + (`tenant_id`, `pay_setting_id`, `platform_type`, `channel_app_id`, `channel_sub_mch_id`, + `receiver_type`, `receiver_account`, `relation_scene`), + KEY `idx_third_mch_fz_relation_default` + (`tenant_id`, `mch_id`, `platform_type`, `is_default`, `status`, `del_flag`), + KEY `idx_third_mch_fz_relation_task` + (`status`, `is_default`, `update_time`, `id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci + COMMENT = '商户第三方支付分账关系'; + +-- 四、旧环境补齐关系引用字段。每个字段独立判断,避免一个缺表/缺列阻断后续迁移。 +SET @task_relation_column_exists = ( + SELECT COUNT(1) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_payment_task' AND COLUMN_NAME = 'fz_relation_id' +); +SET @task_relation_column_sql = IF( + @task_relation_column_exists = 0, + 'ALTER TABLE `third_payment_task` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''third_mch_fz_relation主键''', + 'SELECT 1' +); +PREPARE task_relation_column_stmt FROM @task_relation_column_sql; +EXECUTE task_relation_column_stmt; +DEALLOCATE PREPARE task_relation_column_stmt; + +SET @pay_relation_column_exists = ( + SELECT COUNT(1) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME = 'fz_relation_id' +); +SET @pay_order_table_exists = ( + SELECT COUNT(1) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' +); +SET @pay_relation_column_sql = IF( + @pay_order_table_exists = 1 AND @pay_relation_column_exists = 0, + 'ALTER TABLE `third_pay_order` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''支付创建时绑定的平台分佣关系主键''', + 'SELECT 1' +); +PREPARE pay_relation_column_stmt FROM @pay_relation_column_sql; +EXECUTE pay_relation_column_stmt; +DEALLOCATE PREPARE pay_relation_column_stmt; + +SET @relation_auth_column_exists = ( + SELECT COUNT(1) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_mch_fz_relation' AND COLUMN_NAME = 'app_auth_token' +); +SET @relation_auth_column_sql = IF( + @relation_auth_column_exists = 0, + 'ALTER TABLE `third_mch_fz_relation` ADD COLUMN `app_auth_token` varchar(512) DEFAULT NULL COMMENT ''支付宝第三方代调用授权令牌;仅服务端渠道调用使用''', + 'SELECT 1' +); +PREPARE relation_auth_column_stmt FROM @relation_auth_column_sql; +EXECUTE relation_auth_column_stmt; +DEALLOCATE PREPARE relation_auth_column_stmt; + +SET @relation_ratio_column_exists = ( + SELECT COUNT(1) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_mch_fz_relation' AND COLUMN_NAME = 'fz_ratio' +); +SET @relation_ratio_column_sql = IF( + @relation_ratio_column_exists = 0, + 'ALTER TABLE `third_mch_fz_relation` ADD COLUMN `fz_ratio` decimal(6,3) NOT NULL DEFAULT ''0.000'' COMMENT ''平台抽佣比例,单位百分比,范围0到100,精确到0.001%''', + 'ALTER TABLE `third_mch_fz_relation` MODIFY COLUMN `fz_ratio` decimal(6,3) NOT NULL DEFAULT ''0.000'' COMMENT ''平台抽佣比例,单位百分比,范围0到100,精确到0.001%''' +); +PREPARE relation_ratio_column_stmt FROM @relation_ratio_column_sql; +EXECUTE relation_ratio_column_stmt; +DEALLOCATE PREPARE relation_ratio_column_stmt; + +-- 五、补齐任务和业务扫描索引。索引已存在时不重复创建。 +SET @index_ddl = IF(EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_payment_task' AND INDEX_NAME = 'idx_third_payment_task_relation'), 'SELECT 1', 'ALTER TABLE `third_payment_task` ADD INDEX `idx_third_payment_task_relation` (`tenant_id`, `fz_relation_id`)'); +PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt; + +SET @pay_task_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 5 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('status', 'del_flag', 'pay_platform_type', 'update_time', 'id')); +SET @pay_fz_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 5 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('status', 'enable_fz', 'del_flag', 'pay_time', 'id')); +SET @pay_callback_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 5 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('status', 'is_biz_callback', 'del_flag', 'update_time', 'id')); +SET @pay_close_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 5 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('status', 'biz_type', 'del_flag', 'create_time', 'id')); +SET @pay_tenant_no_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 3 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('tenant_id', 'pay_no', 'del_flag')); +SET @pay_relation_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 2 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('tenant_id', 'fz_relation_id')); +SET @fz_task_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 4 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_fz_order' AND COLUMN_NAME IN ('status', 'del_flag', 'update_time', 'id')); +SET @fz_tenant_no_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 3 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_fz_order' AND COLUMN_NAME IN ('tenant_id', 'pay_no', 'del_flag')); +SET @aggregate_task_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 4 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_aggregate_pay_session' AND COLUMN_NAME IN ('status', 'del_flag', 'update_time', 'id')); +SET @aggregate_close_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 4 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_aggregate_pay_session' AND COLUMN_NAME IN ('status', 'del_flag', 'create_time', 'id')); + +SET @index_ddl = IF(@pay_task_scan_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND INDEX_NAME = 'idx_third_pay_order_task_scan'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_task_scan` (`status`, `del_flag`, `pay_platform_type`, `update_time`, `id`)', 'SELECT 1'); +PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt; +SET @index_ddl = IF(@pay_fz_scan_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND INDEX_NAME = 'idx_third_pay_order_fz_scan'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_fz_scan` (`status`, `enable_fz`, `del_flag`, `pay_time`, `id`)', 'SELECT 1'); +PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt; +SET @index_ddl = IF(@pay_callback_scan_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND INDEX_NAME = 'idx_third_pay_order_callback_scan'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_callback_scan` (`status`, `is_biz_callback`, `del_flag`, `update_time`, `id`)', 'SELECT 1'); +PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt; +SET @index_ddl = IF(@pay_close_scan_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND INDEX_NAME = 'idx_third_pay_order_close_scan'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_close_scan` (`status`, `biz_type`, `del_flag`, `create_time`, `id`)', 'SELECT 1'); +PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt; +SET @index_ddl = IF(@pay_tenant_no_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND INDEX_NAME = 'idx_third_pay_order_tenant_pay_no'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)', 'SELECT 1'); +PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt; +SET @index_ddl = IF(@pay_relation_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND INDEX_NAME = 'idx_third_pay_order_fz_relation'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_fz_relation` (`tenant_id`, `fz_relation_id`)', 'SELECT 1'); +PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt; +SET @index_ddl = IF(@fz_task_scan_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_fz_order' AND INDEX_NAME = 'idx_third_fz_order_task_scan'), 'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_task_scan` (`status`, `del_flag`, `update_time`, `id`)', 'SELECT 1'); +PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt; +SET @index_ddl = IF(@fz_tenant_no_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_fz_order' AND INDEX_NAME = 'idx_third_fz_order_tenant_pay_no'), 'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)', 'SELECT 1'); +PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt; +SET @index_ddl = IF(@aggregate_task_scan_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_aggregate_pay_session' AND INDEX_NAME = 'idx_third_aggregate_task_scan'), 'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_task_scan` (`status`, `del_flag`, `update_time`, `id`)', 'SELECT 1'); +PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt; +SET @index_ddl = IF(@aggregate_close_scan_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_aggregate_pay_session' AND INDEX_NAME = 'idx_third_aggregate_close_scan'), 'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_close_scan` (`status`, `del_flag`, `create_time`, `id`)', 'SELECT 1'); +PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt; + +-- 六、迁移钱包历史配置。只有关系完整、JSON合法且数据已核对时,才清理旧配置键和旧比例列。 +SET @wallet_table_exists = ( + SELECT COUNT(1) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'mch_pay_setting' +); +SET @legacy_ratio_column_exists = ( + SELECT COUNT(1) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'mch_pay_setting' AND COLUMN_NAME = 'fz_ratio' +); +SET @legacy_invalid_json_count = 0; +SET @legacy_invalid_json_sql = IF( + @wallet_table_exists = 1, + 'SELECT COUNT(1) INTO @legacy_invalid_json_count FROM `pjcloudx_wallet`.`mch_pay_setting` WHERE platform_type IN (''10'', ''20'') AND setting_json IS NOT NULL AND TRIM(setting_json) <> '''' AND JSON_VALID(setting_json) = 0', + 'SELECT 1' +); +PREPARE legacy_invalid_json_stmt FROM @legacy_invalid_json_sql; +EXECUTE legacy_invalid_json_stmt; +DEALLOCATE PREPARE legacy_invalid_json_stmt; + +SET @legacy_channel_update_sql = IF( + @wallet_table_exists = 1 AND @legacy_invalid_json_count = 0, + 'UPDATE `pjcloudx_thirdparty`.`third_mch_fz_relation` r JOIN `pjcloudx_wallet`.`mch_pay_setting` s ON s.id = r.pay_setting_id AND s.mch_id = r.mch_id AND s.tenant_id = r.tenant_id SET r.channel_sub_mch_id = CASE WHEN NULLIF(TRIM(r.channel_sub_mch_id), '''') IS NULL THEN NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.subMchid''))), ''''), ''null'') ELSE r.channel_sub_mch_id END, r.account_key = CASE WHEN s.platform_type = ''20'' AND NULLIF(TRIM(r.account_key), '''') IS NULL THEN NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.accountKey''))), ''''), ''null'') ELSE r.account_key END, r.app_auth_token = CASE WHEN s.platform_type = ''20'' AND NULLIF(TRIM(r.app_auth_token), '''') IS NULL THEN NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.appAuthToken''))), ''''), ''null'') ELSE r.app_auth_token END WHERE s.platform_type IN (''10'', ''20'') AND JSON_VALID(s.setting_json) = 1 AND r.del_flag = ''0'' AND r.is_default = ''1''', + 'SELECT 1' +); +PREPARE legacy_channel_update_stmt FROM @legacy_channel_update_sql; +EXECUTE legacy_channel_update_stmt; +DEALLOCATE PREPARE legacy_channel_update_stmt; + +SET @legacy_ratio_update_sql = IF( + @wallet_table_exists = 1 AND @legacy_ratio_column_exists = 1, + 'UPDATE `pjcloudx_thirdparty`.`third_mch_fz_relation` r JOIN `pjcloudx_wallet`.`mch_pay_setting` s ON s.id = r.pay_setting_id AND s.mch_id = r.mch_id AND s.tenant_id = r.tenant_id SET r.fz_ratio = LEAST(GREATEST(COALESCE(s.fz_ratio, 0), 0), 100) WHERE s.del_flag = ''0'' AND r.del_flag = ''0'' AND r.is_default = ''1'' AND COALESCE(s.fz_ratio, 0) > 0 AND COALESCE(r.fz_ratio, 0) = 0', + 'SELECT 1' +); +PREPARE legacy_ratio_update_stmt FROM @legacy_ratio_update_sql; +EXECUTE legacy_ratio_update_stmt; +DEALLOCATE PREPARE legacy_ratio_update_stmt; + +SET @legacy_channel_unmigrated_count = 0; +SET @legacy_channel_unmigrated_sql = IF( + @wallet_table_exists = 1 AND @legacy_invalid_json_count = 0, + 'SELECT COUNT(1) INTO @legacy_channel_unmigrated_count FROM `pjcloudx_wallet`.`mch_pay_setting` s WHERE s.del_flag = ''0'' AND s.platform_type IN (''10'', ''20'') AND JSON_VALID(s.setting_json) = 1 AND (NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.subMchid''))), ''''), ''null'') IS NOT NULL OR NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.accountKey''))), ''''), ''null'') IS NOT NULL OR NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.appAuthToken''))), ''''), ''null'') IS NOT NULL) AND NOT EXISTS (SELECT 1 FROM `pjcloudx_thirdparty`.`third_mch_fz_relation` r WHERE r.tenant_id = s.tenant_id AND r.mch_id = s.mch_id AND r.pay_setting_id = s.id AND r.platform_type = s.platform_type AND r.is_default = ''1'' AND r.del_flag = ''0'' AND (NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.subMchid''))), ''''), ''null'') IS NULL OR r.channel_sub_mch_id = NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.subMchid''))), ''''), ''null'')) AND (s.platform_type <> ''20'' OR NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.accountKey''))), ''''), ''null'') IS NULL OR r.account_key = NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.accountKey''))), ''''), ''null'')) AND (s.platform_type <> ''20'' OR NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.appAuthToken''))), ''''), ''null'') IS NULL OR r.app_auth_token = NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.appAuthToken''))), ''''), ''null'')))', + 'SELECT 1' +); +PREPARE legacy_channel_unmigrated_stmt FROM @legacy_channel_unmigrated_sql; +EXECUTE legacy_channel_unmigrated_stmt; +DEALLOCATE PREPARE legacy_channel_unmigrated_stmt; + +SET @legacy_ratio_unmigrated_count = 0; +SET @legacy_ratio_unmigrated_sql = IF( + @wallet_table_exists = 1 AND @legacy_ratio_column_exists = 1, + 'SELECT COUNT(1) INTO @legacy_ratio_unmigrated_count FROM `pjcloudx_wallet`.`mch_pay_setting` s WHERE COALESCE(s.fz_ratio, 0) > 0 AND s.del_flag = ''0'' AND NOT EXISTS (SELECT 1 FROM `pjcloudx_thirdparty`.`third_mch_fz_relation` r WHERE r.tenant_id = s.tenant_id AND r.mch_id = s.mch_id AND r.pay_setting_id = s.id AND r.is_default = ''1'' AND r.fz_ratio > 0 AND r.del_flag = ''0'')', + 'SELECT 1' +); +PREPARE legacy_ratio_unmigrated_stmt FROM @legacy_ratio_unmigrated_sql; +EXECUTE legacy_ratio_unmigrated_stmt; +DEALLOCATE PREPARE legacy_ratio_unmigrated_stmt; + +SET @legacy_json_cleanup_sql = IF( + @wallet_table_exists = 1 AND @legacy_invalid_json_count = 0 AND @legacy_channel_unmigrated_count = 0 AND @legacy_ratio_unmigrated_count = 0, + 'UPDATE `pjcloudx_wallet`.`mch_pay_setting` SET setting_json = CASE WHEN JSON_LENGTH(JSON_REMOVE(setting_json, ''$.subMchid'', ''$.accountKey'', ''$.appAuthToken'')) = 0 THEN NULL ELSE JSON_REMOVE(setting_json, ''$.subMchid'', ''$.accountKey'', ''$.appAuthToken'') END WHERE platform_type IN (''10'', ''20'') AND JSON_VALID(setting_json) = 1', + 'SELECT 1' +); +PREPARE legacy_json_cleanup_stmt FROM @legacy_json_cleanup_sql; +EXECUTE legacy_json_cleanup_stmt; +DEALLOCATE PREPARE legacy_json_cleanup_stmt; + +SET @drop_legacy_ratio_sql = IF( + @wallet_table_exists = 1 AND @legacy_ratio_column_exists = 1 AND @legacy_invalid_json_count = 0 AND @legacy_channel_unmigrated_count = 0 AND @legacy_ratio_unmigrated_count = 0, + 'ALTER TABLE `pjcloudx_wallet`.`mch_pay_setting` DROP COLUMN `fz_ratio`', + 'SELECT 1' +); +PREPARE drop_legacy_ratio_stmt FROM @drop_legacy_ratio_sql; +EXECUTE drop_legacy_ratio_stmt; +DEALLOCATE PREPARE drop_legacy_ratio_stmt; + +-- 七、输出迁移结果。出现BLOCKED时不要上线新代码,先处理对应数据或数据库前置问题。 +SET @required_table_count = ( + SELECT COUNT(DISTINCT TABLE_NAME) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME IN ('third_aggregate_pay_session', 'third_payment_task', + 'third_payment_task_attempt', 'third_mch_fz_relation', + 'third_pay_order', 'third_fz_order') +); +SET @required_column_count = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND ((TABLE_NAME = 'third_mch_fz_relation' AND COLUMN_NAME IN ('app_auth_token', 'fz_ratio')) + OR (TABLE_NAME = 'third_payment_task' AND COLUMN_NAME = 'fz_relation_id') + OR (TABLE_NAME = 'third_pay_order' AND COLUMN_NAME = 'fz_relation_id')) +); +SET @skipped_scan_index_count = + (@pay_task_scan_ready = 0) + (@pay_fz_scan_ready = 0) + (@pay_callback_scan_ready = 0) + + (@pay_close_scan_ready = 0) + (@pay_tenant_no_ready = 0) + (@pay_relation_ready = 0) + + (@fz_task_scan_ready = 0) + (@fz_tenant_no_ready = 0) + + (@aggregate_task_scan_ready = 0) + (@aggregate_close_scan_ready = 0); + +SELECT CASE + WHEN @required_table_count <> 6 THEN 'BLOCKED_REQUIRED_THIRD_TABLE_NOT_FOUND' + WHEN @required_column_count <> 4 THEN 'BLOCKED_REQUIRED_THIRD_COLUMN_NOT_FOUND' + WHEN @skipped_scan_index_count > 0 THEN 'BLOCKED_SCAN_INDEX_SOURCE_COLUMN_NOT_FOUND' + WHEN @wallet_table_exists = 0 THEN 'BLOCKED_WALLET_TABLE_NOT_FOUND' + WHEN @legacy_invalid_json_count > 0 THEN 'BLOCKED_INVALID_SETTING_JSON' + WHEN @legacy_channel_unmigrated_count > 0 THEN 'BLOCKED_UNMIGRATED_CHANNEL_CONFIG' + WHEN @legacy_ratio_unmigrated_count > 0 THEN 'BLOCKED_UNMIGRATED_POSITIVE_RATIO' + ELSE 'SUCCESS_THIRD_PAYMENT_SCHEMA_MIGRATED' + END AS migration_status, + @legacy_invalid_json_count AS invalid_setting_json_count, + @legacy_channel_unmigrated_count AS unmigrated_channel_config_count, + @legacy_ratio_unmigrated_count AS unmigrated_positive_ratio_count, + @required_table_count AS required_third_table_count, + @required_column_count AS required_third_column_count, + @skipped_scan_index_count AS skipped_scan_index_count; + +SELECT table_name, column_name, column_type +FROM information_schema.COLUMNS +WHERE table_schema = DATABASE() + AND ((table_name = 'third_mch_fz_relation' AND column_name IN ('app_auth_token', 'fz_ratio')) + OR (table_name = 'third_payment_task' AND column_name = 'fz_relation_id') + OR (table_name = 'third_pay_order' AND column_name = 'fz_relation_id')) +ORDER BY table_name, ordinal_position; diff --git a/db/800pjcloudx_wallet.sql b/db/800pjcloudx_wallet.sql index 462e9448..deffa17b 100644 --- a/db/800pjcloudx_wallet.sql +++ b/db/800pjcloudx_wallet.sql @@ -164,9 +164,6 @@ CREATE TABLE mch_pay_setting use_scope varchar(256) NOT NULL COMMENT '使用范围(直接支付,钱包充值)', setting_json text DEFAULT NULL COMMENT '支付json配置', - fz_ratio int NOT NULL COMMENT '分账比例(最高100%即分给对方商户的%)', - - create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人', create_time datetime DEFAULT NULL COMMENT '创建时间', update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人', diff --git a/pjcloud-common/pjcloud-common-data/src/main/java/com/bsd/pjcloud/common/data/datascope/DataScopeInnerInterceptor.java b/pjcloud-common/pjcloud-common-data/src/main/java/com/bsd/pjcloud/common/data/datascope/DataScopeInnerInterceptor.java index 293b6a47..913f1931 100644 --- a/pjcloud-common/pjcloud-common-data/src/main/java/com/bsd/pjcloud/common/data/datascope/DataScopeInnerInterceptor.java +++ b/pjcloud-common/pjcloud-common-data/src/main/java/com/bsd/pjcloud/common/data/datascope/DataScopeInnerInterceptor.java @@ -2,7 +2,6 @@ package com.bsd.pjcloud.common.data.datascope; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.collection.CollectionUtil; -import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSON; import cn.hutool.json.JSONUtil; @@ -53,19 +52,23 @@ public class DataScopeInnerInterceptor implements DataScopeInterceptor { List deptIds = dataScope.getDeptList(); - // 1.无数据权限限制,则直接返回 0 条数据 - if (CollUtil.isEmpty(deptIds) && StrUtil.isBlank(dataScope.getUsername())) { + boolean hasDeptScope = CollUtil.isNotEmpty(deptIds); + boolean hasUserScope = StrUtil.isNotBlank(dataScope.getUsername()) + && StrUtil.isNotBlank(dataScope.getScopeUserName()); + + // 1.无可用权限条件时直接返回 0 条数据,避免生成非法的 IN (),也不能因本人字段被禁用而放开查询。 + if (!hasDeptScope && !hasUserScope) { originalSql = String.format("SELECT %s FROM (%s) temp_data_scope WHERE 1 = 2", dataScope.getFunc().getType(), originalSql); } // 2.如果为本人权限 + 部门权限控制 - else if (StrUtil.isNotBlank(dataScope.getUsername()) && ObjectUtil.isNotEmpty(dataScope.getScopeUserName()) && CollUtil.isNotEmpty(deptIds)) { + else if (hasUserScope && hasDeptScope) { String join = CollectionUtil.join(deptIds, ","); originalSql = String.format("SELECT %s FROM (%s) temp_data_scope WHERE temp_data_scope.%s = '%s' OR temp_data_scope.%s IN (%s)", dataScope.getFunc().getType(), originalSql, dataScope.getScopeUserName(), dataScope.getUsername(), dataScope.getScopeDeptName(), join); } // 3. 如果为本人 - else if (StrUtil.isNotBlank(dataScope.getUsername()) && ObjectUtil.isNotEmpty(dataScope.getScopeUserName())) { + else if (hasUserScope) { originalSql = String.format("SELECT %s FROM (%s) temp_data_scope WHERE temp_data_scope.%s = '%s'", dataScope.getFunc().getType(), originalSql, dataScope.getScopeUserName(), dataScope.getUsername()); } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java index b28ea530..6633dcee 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java @@ -26,7 +26,12 @@ public enum HardAutoRefundTriggerTypeEnum { /** * 管理端业务人员对已到期候选订单确认后执行的逐单退款,不复用技术重试权限。 */ - EXPIRED_MANUAL("40", "后台逐单过期退款", false); + EXPIRED_MANUAL("40", "后台逐单过期退款", false), + + /** + * 管理端按商户当前配置立即重扫历史订单后触发的自动退款,失败后继续进入既有自动重试状态机。 + */ + EXPIRED_RESCAN("50", "后台立即重新扫描", true); /** * 数据库存储的稳定触发编码。 diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java index d124723d..1665a719 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java @@ -163,11 +163,11 @@ public class HardOrderEntity extends Model { @Schema(description = "过期预约退款配置ID快照") private Long expiredRefundConfigId; - /** 预约订单支付成功时解析出的实际餐段结束时间;为空表示无法安全确定过期资格。 */ - @Schema(description = "过期预约餐段实际结束时间快照") + /** 预约下单校验通过时固化的用餐日核销有效期结束时间,固定为 apptUseDate 当天 23:59:59;不受退款开关影响。 */ + @Schema(description = "过期预约用餐日核销结束时间快照") private LocalDateTime expiredRefundUsageEndTime; - /** 实际餐段结束时间加配置延迟后的过期退款触发时间。 */ + /** 用餐日核销有效期结束时间加配置延迟后的过期退款触发时间。 */ @Schema(description = "过期预约退款触发时间") private LocalDateTime expiredRefundDeadline; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardExpiredRefundRescanResultVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardExpiredRefundRescanResultVo.java new file mode 100644 index 00000000..dcfc0c56 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardExpiredRefundRescanResultVo.java @@ -0,0 +1,36 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 商户过期预约退款立即重扫结果,供管理端展示本轮任务补建和退款执行数量。 + */ +@Data +@Schema(description = "过期预约退款立即重扫结果") +public class HardExpiredRefundRescanResultVo { + + /** 本轮读取的已支付、未核销每日菜谱订单数量。 */ + @Schema(description = "扫描订单数量") + private Integer scannedOrderCount; + + /** 本轮首次补建的过期预约退款任务数量。 */ + @Schema(description = "新建任务数量") + private Integer createdTaskCount; + + /** 本轮按当前延迟和退库存配置刷新的未尝试任务数量。 */ + @Schema(description = "刷新任务数量") + private Integer refreshedTaskCount; + + /** 本轮已到达当前触发时间并立即进入退款状态机的任务数量。 */ + @Schema(description = "立即执行任务数量") + private Integer executedTaskCount; + + /** 本轮立即执行后已完成钱包退款和 Hardware 状态更新的任务数量。 */ + @Schema(description = "退款成功任务数量") + private Integer successTaskCount; + + /** 因餐段时间无法解析、已有资金尝试或存在冲突业务状态而未同步的订单数量。 */ + @Schema(description = "跳过订单数量") + private Integer skippedOrderCount; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java index faecc1da..fa5149d9 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java @@ -10,6 +10,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto; import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity; import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity; import com.bsd.pjcloud.hard.api.vo.order.HardAutoRefundTaskVo; +import com.bsd.pjcloud.hard.api.vo.order.HardExpiredRefundRescanResultVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppAutoRefundTaskVo; import com.bsd.pjcloud.hard.service.HardAfterSaleService; import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService; @@ -62,7 +63,7 @@ public class HardAutoRefundTaskController { * 按当前用户商户数据范围分页查询自动退款任务。 * * @param page 分页参数 - * @param dto 筛选条件 + * @param dto 筛选条件 * @return 任务分页 */ @GetMapping("/page") @@ -81,24 +82,40 @@ public class HardAutoRefundTaskController { } /** - * 查询已到期且仍可整单退款的每日菜谱预约订单;失败或重试耗尽任务不混入候选。 + * 按商户详情页传入的精确商户 ID 查询已到期且仍可整单退款的每日菜谱预约订单;失败或重试耗尽任务不混入候选。 + * + * @param page 分页参数 + * @param dto 候选查询条件,其中商户 ID 必填 + * @return 当前商户的待过期退款订单分页 */ @GetMapping("/expired-candidates") @Operation(summary = "查询待过期预约退款订单") @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_view')") - public R> expiredCandidates(@ParameterObject Page page, - @ParameterObject HardAutoRefundTaskPageDto dto) { - List scopedMchIds = listScopedMerchantIds(); - if (scopedMchIds.isEmpty()) { - Page empty = new Page<>(page.getCurrent(), page.getSize(), 0L); - empty.setRecords(List.of()); - return R.ok(empty); - } - dto.setMchIds(scopedMchIds); + public R> expiredCandidates(@ParameterObject Page page, @ParameterObject HardAutoRefundTaskPageDto dto) { + validateMerchantScope(dto.getMchId()); + // 详情接口只能查询一个已校验商户,禁止客户端利用内部批量字段扩大查询范围。 + dto.setMchIds(null); return R.ok(taskService.pageExpiredCandidates(page, dto)); } - /** 管理端确认后逐单执行过期预约整单退款。 */ + /** + * 按商户当前配置立即重扫历史过期预约订单;开关关闭时只同步暂停任务,开关开启时执行已到期任务。 + * + * @param mchId 商户详情页传入的精确商户 ID + * @return 本轮扫描、任务同步和退款执行数量 + */ + @PostMapping("/expired-rescan") + @Operation(summary = "立即重新扫描过期预约退款订单") + @Idempotent(expireTime = 5, info = "正在重新扫描,请勿重复操作") + @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')") + public R expiredRescan(@RequestParam Long mchId) { + validateMerchantScope(mchId); + return R.ok(taskService.rescanExpiredReserveOrders(mchId, SecurityUtils.getUser().getId())); + } + + /** + * 管理端确认后逐单执行过期预约整单退款。 + */ @PostMapping("/{id}/expired-refund") @Operation(summary = "过期预约订单退款") @Idempotent(expireTime = 5, info = "正在退款,请勿重复操作") @@ -168,6 +185,26 @@ public class HardAutoRefundTaskController { } } + /** + * 校验单个商户属于当前管理用户数据范围,供商户详情页的精确查询使用。 + * + * @param mchId 详情页传入的商户 ID + */ + private void validateMerchantScope(Long mchId) { + if (mchId == null) { + throw new RuntimeException("商户不能为空"); + } + MchManageDto query = MchManageDto.builder() + .ids(List.of(mchId)) + .typesList(List.of(MerchantTypeEnum.CANTEEN.getType())) + .build(); + R> response = remoteMchService.scopeList(query); + if (response == null || !response.isOk() || response.getData() == null + || response.getData().stream().noneMatch(item -> item != null && Objects.equals(item.getId(), mchId))) { + throw new RuntimeException("无权访问当前商户"); + } + } + /** * 查询当前管理用户可访问的食堂商户 ID;远程异常拒绝请求,不能降级为全量任务。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java index 22ed964e..ea59db71 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java @@ -115,6 +115,67 @@ public class HardCanteenTotalController { return R.ok(canteenTotalService.proudctMchQuery(page, dto)); } + /** + * 按销售发生时间分页查询 App 食堂综合销售统计。 + * + *

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

+ * + * @param page 按自然日分页的分页参数 + * @param dto 统计日期、来源和餐段筛选条件 + * @return 日期、餐段和来源多维销售统计分页 + */ + @ApiCryptoAes + @Operation(summary = "App食堂综合销售统计", description = "线下按支付时间、预约按核销时间查询日期餐段来源统计") + @PostMapping("/app/proudct-total") + public R> appProductTotal(@ParameterObject Page page, + @RequestBody HardOrderDto dto) { + if (!applyAppCanteenMerchantScope(dto)) { + return R.ok(new Page()); + } + return R.ok(canteenTotalService.productTotal(page, dto, null)); + } + + /** + * 查询 App 食堂综合销售统计中指定日期、餐段和来源的部门明细。 + * + * @param page 保留的分页参数,与 PC 同名接口保持调用形式一致 + * @param dto 统计日期、来源、餐段和部门筛选条件 + * @return 当前用户可见食堂范围内的部门销售汇总 + */ + @ApiCryptoAes + @Operation(summary = "App食堂综合销售部门明细", description = "查询日期餐段来源对应的部门销售统计") + @PostMapping("/app/proudct-total-by-dept") + public R> appProductTotalByDept(@ParameterObject Page page, + @RequestBody HardOrderDto dto) { + if (!applyAppCanteenMerchantScope(dto)) { + return R.ok(List.of()); + } + return R.ok(canteenTotalService.productTotalByDept(page, dto, null)); + } + + /** + * 将 App 统计查询的食堂条件收敛到当前商户管理员的数据范围。 + * + * @param dto 待补全食堂范围的统计请求,已有食堂条件会与可见范围取交集 + * @return 是否仍存在可查询的食堂;false 表示调用方应直接返回空数据 + */ + private boolean applyAppCanteenMerchantScope(HardOrderDto dto) { + PjcloudUser user = SecurityUtils.getUser(); + List allowedMchIds = remoteMchService + .getSelfMchIdList(user.getId(), List.of(MerchantTypeEnum.CANTEEN)).getData(); + if (ObjectUtil.isEmpty(allowedMchIds)) { + dto.setMchIds(List.of()); + return false; + } + List requestedMchIds = dto.getMchIds(); + List scopedMchIds = ObjectUtil.isEmpty(requestedMchIds) + ? allowedMchIds + : requestedMchIds.stream().filter(allowedMchIds::contains).distinct().toList(); + dto.setMchIds(scopedMchIds); + return ObjectUtil.isNotEmpty(scopedMchIds); + } + /* ************************************* start app start ************************************* */ @Operation(summary = "根据部门 食堂菜品购买统计", description = "根据部门 食堂菜品购买统计") @PostMapping("/proudct-total-by-dept") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java index b9936a98..c770ec0a 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java @@ -2,30 +2,23 @@ package com.bsd.pjcloud.hard.controller.order; import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes; -import cn.hutool.core.date.DatePattern; -import cn.hutool.core.date.LocalDateTimeUtil; -import cn.hutool.core.io.resource.ResourceUtil; import cn.hutool.core.util.ObjectUtil; -import com.alibaba.excel.EasyExcel; -import com.alibaba.excel.ExcelWriter; -import com.alibaba.excel.write.metadata.WriteSheet; -import com.alibaba.excel.write.metadata.fill.FillConfig; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.admin.api.dto.UserInfo; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; -import com.bsd.pjcloud.common.core.util.BeanUtils; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum; import com.bsd.pjcloud.common.idempotent.annotation.Idempotent; +import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.common.security.annotation.Inner; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; -import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; @@ -53,7 +46,6 @@ import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateReserveOrdersVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppScanPayOrderVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateShopOrderVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardTakeOutDeliveryTimeOptionVO; -import com.bsd.pjcloud.hard.api.vo.order.excel.HardOrderExcelVo; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.api.vo.order.HardOnlineCanteenStatsVo; import com.bsd.pjcloud.hard.service.*; @@ -74,14 +66,8 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.format.annotation.DateTimeFormat; -import java.io.IOException; import java.time.LocalDate; -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.time.LocalDateTime; -import java.util.HashMap; import java.util.List; -import java.util.Map; /** * 订单表 @@ -100,6 +86,9 @@ public class HardOrderController { private final HardOrderSyncService hardOrderSyncService; private final HardCanteenTotalService canteenTotalService; + /** 订单导出服务,负责异步任务、分批模板写入和文件中心上传。 */ + private final HardOrderExportService hardOrderExportService; + private final HardOrderMapper hardOrderMapper; private final RemoteMchService mchService; @@ -214,47 +203,67 @@ public class HardOrderController { return R.ok(hardwareOrderService.pagejoin(page, dto)); } - @Operation(summary = "app查询食堂订餐的所有商户统计", description = "app查询食堂订餐的所有商户统计") + /** + * 按预约用餐时间统计当前商户管理员可见食堂的预约订单数量。 + * + * @param dto 预约订单状态和预约用餐时间范围;创建、支付等其他时间条件会被忽略 + * @return 各食堂在预约时间范围内的订单统计 + */ + @Operation(summary = "app查询食堂订餐的所有商户统计", description = "按预约用餐时间查询食堂订餐商户统计") @GetMapping("/app/caneen-rserve-mch-total") public R appCaneenRserveMchTotal(@ParameterObject HardOrderDto dto) { DataScope dataScope = null; - PjcloudUser user = SecurityUtils.getUser(); + normalizeAppReservationUseDateQuery(dto); dto.setOrderType(HardOrderTypeEnum.RESERVE.getType()); dto.setTotalIsFindChildren(false); dto.setApptIdOrApptRuleIdNotNull(true); return R.ok(canteenTotalService.listByDept(dto, user, dataScope)); } - @Operation(summary = "app查询食堂订餐订单列表", description = "app查询食堂订餐订单列表") + /** + * 按预约用餐时间分页查询当前商户管理员可核销的食堂预约订单。 + * + * @param page 分页参数 + * @param dto 订单状态、食堂和预约用餐时间范围;创建、支付等其他时间条件会被忽略 + * @return 按预约用餐日期倒序排列的预约订单分页 + */ + @Operation(summary = "app查询食堂订餐订单列表", description = "按预约用餐时间查询食堂订餐订单列表") @GetMapping("/app/caneen-rserve-page-join") public R appCaneenRservePageJoin(@ParameterObject Page page, @ParameterObject HardOrderDto dto) { + normalizeAppReservationUseDateQuery(dto); dto.setIsFindCarList(true); dto.setIsFindApptList(true); dto.setOrderType(HardOrderTypeEnum.RESERVE.getType()); dto.setOrderStatusCodes(List.of(HardOrderStatusEnum.PAY.getType(), HardOrderStatusEnum.CANCEL.getType())); dto.setApptIdOrApptRuleIdNotNull(true); - DataScope dataScope = null; - // 查询商户 - MchManageDto mchManageDto = new MchManageDto(); - mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType())); - List mchList = mchService.scopeList(mchManageDto).getData(); - if (ObjectUtil.isEmpty(mchList)) { + List mchIdList = mchService.getSelfMchIdList(SecurityUtils.getUser().getId(), List.of(MerchantTypeEnum.CANTEEN)).getData(); + if (ObjectUtil.isEmpty(mchIdList)) { return R.ok(new Page()); } - List allowedMchIds = mchList.stream().map(MchManageVo::getId).toList(); - dto.setMchIds(ObjectUtil.isEmpty(dto.getMchIds()) - ? allowedMchIds - : dto.getMchIds().stream().filter(allowedMchIds::contains).toList()); + dto.setMchIds(ObjectUtil.isEmpty(dto.getMchIds()) ? mchIdList : dto.getMchIds().stream().filter(mchIdList::contains).toList()); if (ObjectUtil.isEmpty(dto.getMchIds())) { return R.ok(new Page()); } - return R.ok(hardwareOrderService.scopePageList(page, dto, dataScope)); } + /** + * 统一商户核销页面的预约时间口径,避免历史创建时间或支付时间参数与预约日期形成交叉筛选。 + * + * @param dto 当前两个核销查询接口接收的订单筛选条件,方法会原地清除非预约日期条件 + */ + private void normalizeAppReservationUseDateQuery(HardOrderDto dto) { + dto.setCreateTimeList(null); + dto.setCreateTimeStr(null); + dto.setPayTimeList(null); + dto.setUpdateTimeList(null); + dto.setVerificationTimeList(null); + dto.setStatisticsDateTimeList(null); + } + @Operation(summary = "app查询食堂订单列表", description = "app查询订单列表") @ApiCryptoAes @GetMapping("/app/caneen-page-join") @@ -652,77 +661,30 @@ public class HardOrderController { return R.ok(hardwareOrderService.shipment(dto)); } + /** + * 提交订单异步分批导出任务。 + * + * @param dto 订单页面当前筛选条件;orderIds 非空时仅导出勾选订单 + * @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址 + */ + @Operation(summary = "订单记录异步导出", description = "按订单筛选条件分批查询并流式生成 Excel") + @SysLog("订单记录异步导出") + @PostMapping("/export-task") + @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_export')") + public R submitExport(@RequestBody HardOrderDto dto) { + return R.ok(hardOrderExportService.submitExport(dto), "订单导出任务已提交"); + } + + /** + * 兼容仍使用旧下载方式的订单页面,同步响应内部也由独立导出服务分批生成。 + * + * @param response 当前 HTTP 下载响应 + * @param dto 订单页面当前筛选条件 + */ + @Operation(summary = "订单记录导出", description = "兼容旧页面的同步订单导出") @GetMapping("/export") @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_export')") - public void export(HttpServletResponse response, HardOrderDto dto) throws IOException { - DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL); - dataScope.setScopeDeptName("merchant_dept_id"); - PjcloudUser user = SecurityUtils.getUser(); - CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user); - dto.setMchTypeCodes(hardTypeInfo.getHardTypeList()); - // 查询商户和下面的子商户 - List mchDtoIds = dto.getMchIds(); - if (ObjectUtil.isNotEmpty(mchDtoIds) && mchDtoIds.size() == 1) { - Long mchId = mchDtoIds.get(0); - List mchList = mchService.getByParentId(mchId).getData(); - if (ObjectUtil.isNotEmpty(mchList)) { - List mchIds = mchList.stream().map(MchManageVo::getId).toList(); - mchDtoIds.addAll(mchIds); - dto.setMchIds(mchDtoIds); - } - } - List list = hardwareOrderService.scopeList(dto, DataScope.of()); - List excelList = BeanUtils.toBean(list, HardOrderExcelVo.class); - try (ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).withTemplate(ResourceUtil.getStream("file/订单交易汇总.xlsx")).build()) { - WriteSheet writeSheet = EasyExcel.writerSheet().build(); - FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build(); - Map map = new HashMap(); - if (ObjectUtil.isNotEmpty(dto.getPayTimeList())) { - map.put("timeSpan", " 到 "); - map.put("startTime", LocalDateTimeUtil.format(dto.getPayTimeList()[0], DatePattern.NORM_DATETIME_PATTERN)); - map.put("endTime", LocalDateTimeUtil.format(dto.getPayTimeList()[1], DatePattern.NORM_DATETIME_PATTERN)); - } - if (ObjectUtil.isNotEmpty(excelList)) { - BigDecimal totalPriceSum = BigDecimal.ZERO; - BigDecimal extraPriceSum = BigDecimal.ZERO; - BigDecimal payPriceSum = BigDecimal.ZERO; - Long lineNumber = 1L; - for (HardOrderExcelVo v : excelList) { - v.setLineNumber(lineNumber); - lineNumber++; - if (ObjectUtil.isNotEmpty(v.getTotalPrice())) { - totalPriceSum = totalPriceSum.add(BigDecimal.valueOf(v.getTotalPrice())); - v.setTotalPrice(BigDecimal.valueOf(v.getTotalPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue()); - } - if (ObjectUtil.isNotEmpty(v.getExtraPrice())) { - extraPriceSum = extraPriceSum.add(BigDecimal.valueOf(v.getExtraPrice())); - v.setExtraPrice(BigDecimal.valueOf(v.getExtraPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue()); - } - if (ObjectUtil.isNotEmpty(v.getPayPrice())) { - payPriceSum = payPriceSum.add(BigDecimal.valueOf(v.getPayPrice())); - v.setPayPrice(BigDecimal.valueOf(v.getPayPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue()); - } - if (ObjectUtil.isNotEmpty(v.getHourType())) { - HardHourTypeEnum hardHourTypeEnum = HardHourTypeEnum.valueOfByType(v.getHourType()); - if (ObjectUtil.isNotEmpty(hardHourTypeEnum)) { - v.setHourType(hardHourTypeEnum.getDescription()); - } else { - v.setHourType(""); - } - } - if (ObjectUtil.isNotEmpty(v.getPayType())) { - HardPayTypeEnum hardPayTypeEnum = HardPayTypeEnum.valueOfByType(v.getPayType()); - if (ObjectUtil.isNotEmpty(hardPayTypeEnum)) { - v.setPayType(hardPayTypeEnum.getDescription()); - } - } - } - map.put("totalPriceSum", totalPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - map.put("extraPriceSum", extraPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - map.put("payPriceSum", payPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - } - excelWriter.fill(map, writeSheet); - excelWriter.fill(excelList, fillConfig, writeSheet); - } + public void export(HttpServletResponse response, HardOrderDto dto) { + hardOrderExportService.export(response, dto); } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java index 911c3d71..56e69579 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java @@ -5,10 +5,12 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto; import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import java.time.LocalDateTime; +import java.util.List; /** * 自动退款任务 Mapper,任务执行通过主键行锁串行化多实例竞争。 @@ -64,4 +66,12 @@ public interface HardAutoRefundTaskMapper extends PjcloudBaseMapper pageExpiredCandidates(Page page, @Param("dto") HardAutoRefundTaskPageDto dto, @Param("now") LocalDateTime now); + + /** + * 查询指定商户仍处于已支付未核销状态且没有冲突售后的每日菜谱钱包订单,供立即重扫补建任务。 + * + * @param mchId 精确商户 ID + * @return 按订单主键升序排列的可重扫订单 + */ + List listExpiredRescanOrders(@Param("mchId") Long mchId); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java index fa8a34ee..2f96b3b9 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java @@ -39,7 +39,7 @@ public interface HardApptReservationService { * @param apptDate 实际用餐日期 * @param apptRuleId 周期预约规则 ID;动态模式为空 * @param eligibilityDeadline 当前已校验预约窗口的截止时间,使用服务器现有预约时间口径 - * @param usageEndDateTime 实际用餐餐段的结束时间;无法从有效餐段唯一解析时为空,禁止用于过期退款 + * @param usageEndDateTime 实际用餐日的核销有效期结束时间,固定为该日 23:59:59,供订单固化后重扫使用 */ record ReservationValidation(HardApptEntity appt, String hourType, LocalDate apptDate, Long apptRuleId, LocalDateTime eligibilityDeadline, LocalDateTime usageEndDateTime) { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java index 212fb749..3c5695af 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java @@ -9,6 +9,7 @@ import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity; import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import com.bsd.pjcloud.hard.api.vo.order.HardAutoRefundTaskVo; +import com.bsd.pjcloud.hard.api.vo.order.HardExpiredRefundRescanResultVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppAutoRefundTaskVo; import java.util.List; @@ -63,6 +64,15 @@ public interface HardAutoRefundTaskService extends IService pageExpiredCandidates(Page page, HardAutoRefundTaskPageDto dto); + /** + * 按商户当前过期预约配置重扫历史已支付未核销订单,补建或刷新任务并立即执行已到期任务。 + * + * @param mchId 需要重扫的商户 ID + * @param operatorUserId 发起重扫的后台用户 ID,用于尝试日志审计 + * @return 本轮扫描、任务同步和退款执行数量 + */ + HardExpiredRefundRescanResultVo rescanExpiredReserveOrders(Long mchId, Long operatorUserId); + /** * 查询任务的只读尝试日志,按尝试序号升序返回。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderExportService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderExportService.java new file mode 100644 index 00000000..037c8de2 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderExportService.java @@ -0,0 +1,30 @@ +package com.bsd.pjcloud.hard.service; + +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; +import jakarta.servlet.http.HttpServletResponse; + +/** + * 硬件订单导出服务。 + * + *

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

+ */ +public interface HardOrderExportService { + + /** + * 提交硬件订单异步分批导出任务。 + * + * @param dto 订单页面当前筛选条件;orderIds 非空时仅导出勾选订单 + * @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址 + */ + AsyncTaskProgressVO submitExport(HardOrderDto dto); + + /** + * 兼容旧页面的同步导出入口,同样按页查询并通过临时文件流式生成工作簿。 + * + * @param response 当前 HTTP 下载响应,方法会写入完整 XLSX 文件 + * @param dto 订单页面当前筛选条件 + */ + void export(HttpServletResponse response, HardOrderDto dto); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java index 43a8c4ba..2dddc62b 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java @@ -358,11 +358,10 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic throw new RuntimeException(availability.unavailableReason()); } return new ReservationValidation(appt, appt.getHourType(), targetDate, null, availability.endDateTime(), - availability.endDateTime()); + resolveUsageEndDateTime(targetDate)); } - private ReservationValidation validateCycleReservation(Long mchId, Long apptRuleId, String hourType, - LocalDate apptDate, LocalDateTime now) { + private ReservationValidation validateCycleReservation(Long mchId, Long apptRuleId, String hourType, LocalDate apptDate, LocalDateTime now) { if (apptDate == null || HardHourTypeEnum.valueOfByType(hourType) == null) { throw new RuntimeException("周期预约需要选择预约日期和餐端"); } @@ -379,33 +378,18 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic throw new RuntimeException(match.availability().unavailableReason()); } return new ReservationValidation(null, hourType, apptDate, selected.getId(), match.availability().endDateTime(), - resolveCycleUsageEndDateTime(mchId, hourType, apptDate)); + resolveUsageEndDateTime(apptDate)); } /** - * 周期预约的开放窗口结束时间不是餐段结束时间;这里按已启用的实际 hard_appt 餐段解析, - * 无法唯一解析时返回空值,调用方会放弃过期退款快照以避免误退。 + * 预约订单的核销有效期按实际用餐日全天计算,统一固化为当天 23:59:59。 + * 该口径不读取当前餐段配置,避免预约配置高频调整后影响已创建订单。 * - * @param mchId 订单所属商户 ID - * @param hourType 订单选中的标准餐段编码 * @param apptDate 实际用餐日期 - * @return 匹配餐段的实际结束时间;缺失、重复或时间配置异常时返回 {@code null} + * @return 当天 23:59:59;用餐日期为空时返回 {@code null} */ - private LocalDateTime resolveCycleUsageEndDateTime(Long mchId, String hourType, LocalDate apptDate) { - List candidates = hardApptService.list(new LambdaQueryWrapper() - .eq(HardApptEntity::getMchId, mchId) - .eq(HardApptEntity::getHourType, hourType) - .eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode()) - .orderByAsc(HardApptEntity::getId)); - if (candidates.size() != 1) { - return null; - } - HardApptEntity appt = candidates.get(0); - HardApptTimeTypeEnum endType = HardApptTimeTypeEnum.valueOfByType(appt.getEndTimeType()); - if (endType == null || appt.getEndTime() == null) { - return null; - } - return apptDate.plusDays(endType.getDay()).atTime(appt.getEndTime()); + private LocalDateTime resolveUsageEndDateTime(LocalDate apptDate) { + return apptDate == null ? null : apptDate.atTime(23, 59, 59); } private CycleMatch matchCycleRule(HardApptCycleRuleVo rule, String hourType, diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java index 7b7fff23..bb4b0c7c 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java @@ -19,12 +19,14 @@ import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundAttemptResultEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskTypeEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum; +import com.bsd.pjcloud.hard.api.constant.refund.HardSaleStatusEnum; import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto; import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity; import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity; import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import com.bsd.pjcloud.hard.api.vo.order.HardAutoRefundTaskVo; +import com.bsd.pjcloud.hard.api.vo.order.HardExpiredRefundRescanResultVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppAutoRefundTaskVo; import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleMapper; import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundAttemptLogMapper; @@ -108,6 +110,21 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl pageExpiredCandidates(Page page, - HardAutoRefundTaskPageDto dto) { + public Page pageExpiredCandidates(Page page, HardAutoRefundTaskPageDto dto) { LocalDateTime now = LocalDateTime.now(); Page entityPage = baseMapper.pageExpiredCandidates(page, dto, now); List records = BeanUtils.toBean(entityPage.getRecords(), HardAutoRefundTaskVo.class); @@ -981,7 +1003,193 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl MAX_EXPIRED_RESERVE_DELAY_HOURS) { + throw new RuntimeException("过期预约退款延迟必须在1到168小时之间"); + } + boolean enabled = Objects.equals(config.getExpiredReserveRefundEnabled(), YesNoEnum.YES.getCode()); + String restoreInventory = Objects.equals(config.getExpiredReserveRefundRestoreInventory(), YesNoEnum.YES.getCode()) ? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode(); + List orders = baseMapper.listExpiredRescanOrders(mchId); + HardExpiredRefundRescanResultVo result = new HardExpiredRefundRescanResultVo(); + result.setScannedOrderCount(orders == null ? 0 : orders.size()); + result.setCreatedTaskCount(0); + result.setRefreshedTaskCount(0); + result.setExecutedTaskCount(0); + result.setSuccessTaskCount(0); + result.setSkippedOrderCount(0); + if (orders == null || orders.isEmpty()) { + return result; + } + + LocalDateTime now = LocalDateTime.now(); + for (HardOrderEntity order : orders) { + boolean taskSynced = false; + try { + LocalDateTime usageEndTime = resolveExpiredRefundUsageEndTimeSnapshot(order); + if (usageEndTime == null) { + result.setSkippedOrderCount(result.getSkippedOrderCount() + 1); + log.warn("|{}|立即重扫跳过缺少可靠订单时间快照的订单,orderId:{}", TAG, order.getId()); + continue; + } + LocalDateTime triggerTime = usageEndTime.plusHours(delayHours); + LockInfo syncLock = lockTemplate.lock(REFUND_ORDER_LOCK_PREFIX + order.getId(), + REFUND_ORDER_LOCK_EXPIRE_MILLIS, REFUND_ORDER_LOCK_WAIT_MILLIS); + if (syncLock == null) { + result.setSkippedOrderCount(result.getSkippedOrderCount() + 1); + continue; + } + int syncResult; + try { + syncResult = getSelf().syncExpiredRescanTask(order, config, triggerTime, restoreInventory, enabled); + } finally { + try { + lockTemplate.releaseLock(syncLock); + } catch (Exception exception) { + log.warn("|{}|释放立即重扫订单锁失败,orderId:{},exceptionType:{}", TAG, order.getId(), + exception.getClass().getSimpleName()); + } + } + if (syncResult == RESCAN_SYNC_CREATED) { + result.setCreatedTaskCount(result.getCreatedTaskCount() + 1); + } else if (syncResult == RESCAN_SYNC_REFRESHED) { + result.setRefreshedTaskCount(result.getRefreshedTaskCount() + 1); + } else { + result.setSkippedOrderCount(result.getSkippedOrderCount() + 1); + continue; + } + taskSynced = true; + if (enabled && !triggerTime.isAfter(now)) { + HardAutoRefundTaskEntity task = baseMapper.selectOne(new LambdaQueryWrapper() + .eq(HardAutoRefundTaskEntity::getOrderId, order.getId()) + .eq(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())); + if (task != null && Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.PENDING.getStatus())) { + result.setExecutedTaskCount(result.getExecutedTaskCount() + 1); + execute(task.getId(), HardAutoRefundTriggerTypeEnum.EXPIRED_RESCAN, operatorUserId); + HardAutoRefundTaskEntity completed = getById(task.getId()); + if (completed != null && Objects.equals(completed.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) { + result.setSuccessTaskCount(result.getSuccessTaskCount() + 1); + } + } + } + } catch (Exception exception) { + if (!taskSynced) { + result.setSkippedOrderCount(result.getSkippedOrderCount() + 1); + } + log.warn("|{}|立即重扫订单失败,orderId:{},exceptionType:{}", TAG, order.getId(), + exception.getClass().getSimpleName()); + } + } + return result; + } + + /** + * 解析订单自身保存的过期退款时间快照。新订单优先使用专用时间字段;历史订单仅使用订单上的预约日期 + * 兜底为当天 23:59:59,绝不读取当前预约配置或旧的自动退款截止时间。 + * + * @param order 待重扫的已支付预约订单 + * @return 可信的核销有效期结束时间;订单没有预约日期时返回 {@code null} + */ + private LocalDateTime resolveExpiredRefundUsageEndTimeSnapshot(HardOrderEntity order) { + if (order == null) { + return null; + } + if (order.getExpiredRefundUsageEndTime() != null) { + return order.getExpiredRefundUsageEndTime(); + } + return order.getApptUseDate() == null ? null : order.getApptUseDate().atTime(23, 59, 59); + } + + /** + * 同步单个历史订单的过期任务。已成功、已创建售后或已产生资金尝试的任务不允许被新配置覆盖,避免重扫造成重复退款。 + * + * @param order 历史已支付未核销订单 + * @param config 当前商户过期退款配置 + * @param triggerTime 按当前延迟重新计算的触发时间 + * @param restoreInventory 当前退库存快照 + * @param enabled 当前过期退款开关 + * @return 新建、刷新或跳过结果编码 + */ + @Transactional(rollbackFor = Exception.class) + public int syncExpiredRescanTask(HardOrderEntity order, MchAutoRefundConfigVo config, + LocalDateTime triggerTime, String restoreInventory, boolean enabled) { + if (order == null || order.getId() == null || config == null || config.getId() == null || triggerTime == null) { + return RESCAN_SYNC_SKIPPED; + } + HardOrderEntity currentOrder = hardOrderMapper.selectByIdForUpdate(order.getId()); + if (currentOrder == null || !Objects.equals(currentOrder.getMerchantId(), config.getMchId()) + || !Objects.equals(currentOrder.getOrderType(), HardOrderTypeEnum.RESERVE.getType()) + || !Objects.equals(currentOrder.getOrderStatus(), HardOrderStatusEnum.PAY.getType()) + || !Objects.equals(currentOrder.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType()) + || currentOrder.getPayPrice() == null || currentOrder.getPayPrice() <= 0 + || currentOrder.getRefundPrice() != null && currentOrder.getRefundPrice() > 0) { + return RESCAN_SYNC_SKIPPED; + } + Long conflictingAfterSaleCount = afterSaleMapper.selectCount(new LambdaQueryWrapper() + .eq(HardAfterSaleEntity::getOrderId, currentOrder.getId()) + .in(HardAfterSaleEntity::getStatus, + List.of(HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus()))); + if (conflictingAfterSaleCount != null && conflictingAfterSaleCount > 0) { + return RESCAN_SYNC_SKIPPED; + } + HardAutoRefundTaskEntity existing = baseMapper.selectOne(new LambdaQueryWrapper() + .eq(HardAutoRefundTaskEntity::getOrderId, currentOrder.getId()) + .eq(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())); + if (existing != null) { + boolean refreshableStatus = List.of(HardAutoRefundTaskStatusEnum.PENDING.getStatus(), + HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus(), + HardAutoRefundTaskStatusEnum.CONFIG_UNAVAILABLE.getStatus()).contains(existing.getStatus()); + if (!refreshableStatus || !Objects.equals(existing.getMchId(), currentOrder.getMerchantId()) + || existing.getAfterSaleId() != null || safeCount(existing.getAutoAttemptCount()) > 0) { + return RESCAN_SYNC_SKIPPED; + } + existing.setConfigId(config.getId()); + existing.setTriggerTime(triggerTime); + existing.setRestoreInventory(restoreInventory); + existing.setStatus(enabled ? HardAutoRefundTaskStatusEnum.PENDING.getStatus() + : HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus()); + existing.setNextRetryTime(enabled ? triggerTime : null); + existing.setLastError(null); + if (baseMapper.updateById(existing) != 1) { + throw new RuntimeException("刷新过期预约退款任务失败"); + } + return RESCAN_SYNC_REFRESHED; + } + HardAutoRefundTaskEntity task = HardAutoRefundTaskEntity.builder() + .taskType(HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) + .orderId(currentOrder.getId()).mchId(currentOrder.getMerchantId()) + .configId(config.getId()).triggerTime(triggerTime) + .restoreInventory(restoreInventory) + .status(enabled ? HardAutoRefundTaskStatusEnum.PENDING.getStatus() + : HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus()) + .autoAttemptCount(0).totalAttemptCount(0) + .nextRetryTime(enabled ? triggerTime : null) + .build(); + if (baseMapper.insert(task) != 1) { + throw new RuntimeException("创建过期预约退款任务失败"); + } + return RESCAN_SYNC_CREATED; + } + + /** + * 批量补充过期候选订单日期和餐段,避免把配置变更后的规则重新解析为退款时间。 + */ private void fillOrderData(List records) { List orderIds = records.stream().map(HardAutoRefundTaskVo::getOrderId).filter(Objects::nonNull).distinct().toList(); if (orderIds.isEmpty()) return; @@ -1092,7 +1300,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl expired.ne(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) .or(expiredType -> expiredType.eq(HardAutoRefundTaskEntity::getTaskType, - HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) + HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) .le(HardAutoRefundTaskEntity::getTriggerTime, now))))) .orderByAsc(HardAutoRefundTaskEntity::getNextRetryTime) .orderByAsc(HardAutoRefundTaskEntity::getId) @@ -1187,7 +1395,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl listAllFilteredRows(HardCanteenUserApptQueryDto query, - List queryDates) { + private List listAllFilteredRows(HardCanteenUserApptQueryDto query, List queryDates) { List users = fetchAllUsers(query); return filterRows(buildRows(users, query, queryDates), query); } @@ -536,8 +535,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ * @return 含准确总数的用户分页 */ private Page fetchUserPage(long current, long size, HardCanteenUserApptQueryDto query) { - R> response = remoteUserService.getScopeBasicUserPage(current, size, - buildUserQuery(query)); + R> response = remoteUserService.getScopeBasicUserPage(current, size, buildUserQuery(query)); if (response == null || !response.isOk() || response.getData() == null) { String message = response == null ? null : response.getMsg(); throw new IllegalStateException(StrUtil.isBlank(message) ? "查询权限用户列表失败" : message); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java index 43bc3287..aa750f30 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java @@ -30,6 +30,7 @@ import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate; import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.hard.api.constant.order.HardCanteenStatisticsLevelEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; import com.bsd.pjcloud.hard.api.constant.order.HardSaleSourceEnum; @@ -50,6 +51,7 @@ import com.bsd.pjcloud.hard.excel.HardCanteenProductApptExportMergeHandler; import com.bsd.pjcloud.hard.mapper.order.HardCarMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.HardCanteenTotalService; +import com.bsd.pjcloud.hard.support.HardUserIdentitySupport; import com.bsd.pjcloud.wallet.api.dto.MchManageDto; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; @@ -97,81 +99,128 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { static private final String TAG = "食堂统计"; - /** 当前用户没有可见食堂时使用的不可命中主键,防止空集合被解释为不限制商户。 */ + /** + * 当前用户没有可见食堂时使用的不可命中主键,防止空集合被解释为不限制商户。 + */ private static final Long NO_VISIBLE_CANTEEN_ID = Long.MIN_VALUE; - /** 菜品统计异步导出的 Redis 业务命名空间。 */ + /** + * 菜品统计异步导出的 Redis 业务命名空间。 + */ private static final String PRODUCT_SALES_EXPORT_REDIS_KEY = "hard:canteen-sales:export:product"; - /** 部门统计异步导出的 Redis 业务命名空间。 */ + /** + * 部门统计异步导出的 Redis 业务命名空间。 + */ private static final String DEPARTMENT_SALES_EXPORT_REDIS_KEY = "hard:canteen-sales:export:department"; - /** 菜品统计异步导出任务展示名称。 */ + /** + * 菜品统计异步导出任务展示名称。 + */ private static final String PRODUCT_SALES_EXPORT_TASK_NAME = "食堂菜品销售统计导出"; - /** 部门统计异步导出任务展示名称。 */ + /** + * 部门统计异步导出任务展示名称。 + */ private static final String DEPARTMENT_SALES_EXPORT_TASK_NAME = "食堂菜品部门统计导出"; - /** 异步导出提交时使用的占位总数,后台开始后会替换为实际日期批次数。 */ + /** + * 异步导出提交时使用的占位总数,后台开始后会替换为实际日期批次数。 + */ private static final int SALES_EXPORT_INITIAL_TOTAL = 1; - /** 流式写完日期批次后的工作簿收尾和文件上传进度步数。 */ + /** + * 流式写完日期批次后的工作簿收尾和文件上传进度步数。 + */ private static final int SALES_EXPORT_EXTRA_PROGRESS_STEPS = 2; - /** 三级统计 Excel 的双层表头行数。 */ + /** + * 三级统计 Excel 的双层表头行数。 + */ private static final int SALES_EXPORT_HEADER_ROW_COUNT = 2; - /** 单个三级统计工作表允许写入的最大业务行数。 */ + /** + * 单个三级统计工作表允许写入的最大业务行数。 + */ private static final int MAX_SALES_EXPORT_ROWS = BatchExcelExportTemplate.calculateMaxDetailRowCount(SALES_EXPORT_HEADER_ROW_COUNT, 0); - /** 统一销售统计异步导出的工作表名称。 */ + /** + * 统一销售统计异步导出的工作表名称。 + */ private static final String SALES_EXPORT_SHEET_NAME = "食堂销售统计"; - /** 统一销售统计异步导出的文件中心目录。 */ + /** + * 统一销售统计异步导出的文件中心目录。 + */ private static final String SALES_EXPORT_FILE_DIR = "hard-export"; - /** 菜品统计导出文件的文件中心业务类型。 */ + /** + * 菜品统计导出文件的文件中心业务类型。 + */ private static final String PRODUCT_SALES_EXPORT_FILE_TYPE = "HARD_CANTEEN_PRODUCT_SALES_EXPORT"; - /** 部门统计导出文件的文件中心业务类型。 */ + /** + * 部门统计导出文件的文件中心业务类型。 + */ private static final String DEPARTMENT_SALES_EXPORT_FILE_TYPE = "HARD_CANTEEN_DEPARTMENT_SALES_EXPORT"; - /** 导出聚合记录中的日期餐段汇总层级编码,必须与 Mapper SQL 返回值一致。 */ + /** + * 导出聚合记录中的日期餐段汇总层级编码,必须与 Mapper SQL 返回值一致。 + */ private static final String SALES_EXPORT_RECORD_LEVEL_SUMMARY = "SUMMARY"; - /** 导出聚合记录中的商户部门层级编码,必须与 Mapper SQL 返回值一致。 */ + /** + * 导出聚合记录中的商户部门层级编码,必须与 Mapper SQL 返回值一致。 + */ private static final String SALES_EXPORT_RECORD_LEVEL_MERCHANT = "MERCHANT"; - /** 导出聚合记录中的商品层级编码,必须与 Mapper SQL 返回值一致。 */ + /** + * 导出聚合记录中的商品层级编码,必须与 Mapper SQL 返回值一致。 + */ private static final String SALES_EXPORT_RECORD_LEVEL_PRODUCT = "PRODUCT"; - /** XLSX 文件的标准 MIME 类型。 */ + /** + * XLSX 文件的标准 MIME 类型。 + */ private static final String SALES_EXPORT_EXCEL_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; - /** 异步导出文件名使用的时间格式,保证同一用户连续导出时文件名可区分。 */ + /** + * 异步导出文件名使用的时间格式,保证同一用户连续导出时文件名可区分。 + */ private static final DateTimeFormatter SALES_EXPORT_FILE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); private final HardOrderMapper hardOrderMapper; private final HardCarMapper hardCarMapper; + /** 用户身份查询支持器,负责通过 UPMS 将身份资料条件收敛为订单用户主键。 */ + private final HardUserIdentitySupport userIdentitySupport; + private final RemoteDeptService remoteDeptService; private final RemoteMchService remoteMchService; private final RemoteDataScopeService dataScopeService; private final RemoteCanteenCategoryService canteenCategoryService; - /** 每日菜谱远程服务,用于在三级导出中按业务日期展开套餐组成商品。 */ + /** + * 每日菜谱远程服务,用于在三级导出中按业务日期展开套餐组成商品。 + */ private final RemoteDailyMenuService remoteDailyMenuService; - /** 餐段字典远程服务,仅在生成统计导出文案时读取 daily_menu。 */ + /** + * 餐段字典远程服务,仅在生成统计导出文案时读取 daily_menu。 + */ private final RemoteDictService remoteDictService; - /** 文件中心远程服务,用于保存异步导出完成后的 XLSX 文件。 */ + /** + * 文件中心远程服务,用于保存异步导出完成后的 XLSX 文件。 + */ private final RemoteFileService remoteFileService; - /** 通用异步任务执行器,用于恢复租户和用户上下文并持久化导出进度。 */ + /** + * 通用异步任务执行器,用于恢复租户和用户上下文并持久化导出进度。 + */ private final AsyncTaskExecutorService asyncTaskExecutorService; /** @@ -285,8 +334,8 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { *

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

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

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

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

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

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

+ * + * @param dto 部门统计查询条件;方法会原地写入 userIds 并清除已经消费的身份资料条件 + */ + private void applyUserIdentityQuery(HardOrderDto dto) { + if (!userIdentitySupport.hasIdentityQuery(dto)) { + return; + } + dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds())); + dto.setUserId(null); + dto.setIdentityTypes(null); + dto.setStudentInfo(null); + dto.setTeacherInfo(null); + dto.setHasFace(null); + } + /** * 将部门统计 Mapper 主行装配为“日期 + 食堂合计 -> 来源拆分”的展示结构。 * - * @param rows Mapper 返回的日期食堂合计行和日期食堂来源行 + * @param rows Mapper 返回的日期食堂合计行和日期食堂来源行 * @param includeSourceRows 是否挂载来源拆分;仅多来源视图需要挂载 * @return 日期食堂合计行列表,顺序沿用 Mapper 的日期和食堂排序 */ private List assembleDepartmentMainRows(List rows, - boolean includeSourceRows) { + boolean includeSourceRows) { if (ObjectUtil.isEmpty(rows)) return List.of(); List sourceRows = rows.stream() .filter(row -> ObjectUtil.isNotEmpty(row.getSaleSource())) @@ -525,14 +592,14 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { /** * 将部门展开 Mapper 行装配为“部门 + 餐段合计 -> 来源拆分”的展示结构。 * - * @param rows Mapper 返回的部门餐段合计行和部门餐段来源行 - * @param merchantId 当前展开食堂主键,用于明细按钮继续继承食堂范围 + * @param rows Mapper 返回的部门餐段合计行和部门餐段来源行 + * @param merchantId 当前展开食堂主键,用于明细按钮继续继承食堂范围 * @param includeSourceRows 是否挂载来源拆分;仅多来源视图需要挂载 * @return 部门餐段合计行列表 */ private List assembleDepartmentChildRows(List rows, - Long merchantId, - boolean includeSourceRows) { + Long merchantId, + boolean includeSourceRows) { if (ObjectUtil.isEmpty(rows)) return List.of(); rows.forEach(row -> row.setMchId(merchantId)); List sourceRows = rows.stream() @@ -558,18 +625,18 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { *

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

* - * @param dto 已补全可见食堂、来源和统计日期的查询条件 - * @param mainRows 日期食堂合计主行,每行可能挂载来源拆分 + * @param dto 已补全可见食堂、来源和统计日期的查询条件 + * @param mainRows 日期食堂合计主行,每行可能挂载来源拆分 * @param includeSourceRows 是否为合计行挂载来源拆分;单来源标签页为 false */ private void populateDepartmentStatisticsChildren(HardOrderDto dto, - List mainRows, - boolean includeSourceRows) { + List mainRows, + boolean includeSourceRows) { List allChildRows = hardCarMapper.listByHourTypeStats(dto); Map> childRowsByMainScope = ObjectUtil.isEmpty(allChildRows) ? Map.of() : allChildRows.stream().collect(Collectors.groupingBy(this::departmentMainScopeKey, - LinkedHashMap::new, Collectors.toList())); + LinkedHashMap::new, Collectors.toList())); for (HardCanteenProductTotalVo mainRow : mainRows) { List deptList = assembleDepartmentChildRows( childRowsByMainScope.getOrDefault(departmentMainScopeKey(mainRow), List.of()), @@ -586,12 +653,12 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { /** * 从餐段合计行中提取指定来源的部门餐段行,供主表来源行展开时复用同一批查询结果。 * - * @param totalRows 部门餐段合计行,每行可能挂载来源拆分 + * @param totalRows 部门餐段合计行,每行可能挂载来源拆分 * @param saleSource 主表来源行对应的稳定来源编码 * @return 指定来源的部门餐段行;没有匹配数据时返回空列表 */ private List departmentSourceChildren(List totalRows, - String saleSource) { + String saleSource) { if (ObjectUtil.isEmpty(totalRows) || ObjectUtil.isEmpty(saleSource)) return List.of(); return totalRows.stream() .flatMap(row -> row.getSourceRows().stream()) @@ -654,7 +721,7 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { /** * 将父级业务维度复制到缺少对应值的子级统计行,并继续处理其下级数据。 * - * @param parent 提供食堂、部门、日期和餐段上下文的父级统计行 + * @param parent 提供食堂、部门、日期和餐段上下文的父级统计行 * @param children 待补充上下文的合计行或来源拆分行 */ private void inheritDepartmentRowContext(HardCanteenProductTotalVo parent, @@ -678,7 +745,7 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { *

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

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

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

* - * @param dto 页面提交的销售统计筛选条件 - * @param redisKey 当前导出入口独立使用的 Redis 业务命名空间 - * @param taskName 进度弹窗展示的任务名称 + * @param dto 页面提交的销售统计筛选条件 + * @param redisKey 当前导出入口独立使用的 Redis 业务命名空间 + * @param taskName 进度弹窗展示的任务名称 * @param fileNamePrefix 下载文件名称前缀 - * @param fileType 文件中心保存的业务类型编码 + * @param fileType 文件中心保存的业务类型编码 * @return 已创建的初始任务进度 */ private AsyncTaskProgressVO submitSalesExport(HardOrderDto dto, String redisKey, String taskName, - String fileNamePrefix, String fileType) { + String fileNamePrefix, String fileType) { if (!isSourceStatistics(dto)) { throw new IllegalArgumentException("导出必须选择统计日期范围"); } @@ -760,10 +827,10 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { *

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

* - * @param query 已冻结权限范围的销售统计查询 + * @param query 已冻结权限范围的销售统计查询 * @param fileNamePrefix 下载文件名称前缀 - * @param fileType 文件中心业务类型编码 - * @param reporter 当前异步任务进度报告器 + * @param fileType 文件中心业务类型编码 + * @param reporter 当前异步任务进度报告器 * @return 文件中心下载地址和业务文件名 */ private AsyncTaskResult exportSalesToFile(HardOrderDto query, String fileNamePrefix, String fileType, @@ -795,9 +862,9 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { * 按自然日批次查询三级统计行并写入同一个流式工作簿。 * * @param outputPath 上传前使用的临时 XLSX 文件 - * @param query 已冻结的完整筛选条件 - * @param dates 从开始日期到结束日期的有序自然日列表 - * @param reporter 当前任务进度报告器 + * @param query 已冻结的完整筛选条件 + * @param dates 从开始日期到结束日期的有序自然日列表 + * @param reporter 当前任务进度报告器 * @return 实际写入的业务行总数 */ private long writeSalesExportWorkbook(Path outputPath, HardOrderDto query, List dates, @@ -833,7 +900,7 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { * 创建单个自然日的查询副本,并保留用户在首尾日期选择的具体时间边界。 * * @param query 已冻结的完整日期范围查询 - * @param date 当前待处理的自然日 + * @param date 当前待处理的自然日 * @return 日期范围已收敛到当前自然日的独立查询 */ private HardOrderDto createExportDateQuery(HardOrderDto query, LocalDate date) { @@ -853,8 +920,8 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { * 将已关闭的完整 XLSX 临时文件上传到统一文件中心。 * * @param outputPath 已完成流式写入的临时文件 - * @param fileName 用户下载时使用的 UTF-8 文件名 - * @param fileType 文件中心业务类型编码 + * @param fileName 用户下载时使用的 UTF-8 文件名 + * @param fileType 文件中心业务类型编码 * @return 文件中心返回的下载地址 * @throws Exception 文件读取或远程上传失败时抛出 */ @@ -932,7 +999,7 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { /** * 查询一个自然日的三层销售数据并转换为 Excel 行。 * - * @param query 已收敛到单个自然日的查询条件 + * @param query 已收敛到单个自然日的查询条件 * @param fixedDate 当前批次业务日期 * @param dataScope 数据权限上下文 * @return 当前日期下合计和各来源的日期餐段、商户部门、商品明细及商品统计行 @@ -1037,7 +1104,7 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { /** * 从一次 SQL 返回的三级聚合记录中提取指定层级。 * - * @param records 当前自然日的全部导出聚合记录;允许为空 + * @param records 当前自然日的全部导出聚合记录;允许为空 * @param recordLevel Mapper 返回的 SUMMARY、MERCHANT 或 PRODUCT 层级编码 * @return 保持数据库稳定排序的指定层级记录 */ @@ -1056,7 +1123,7 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { * 之和严格等于订单快照金额。找不到套餐菜谱时保留原商品行。

* * @param product 商品聚合记录 - * @param menu 与商户、日期、餐段和商品匹配的每日菜谱 + * @param menu 与商户、日期、餐段和商品匹配的每日菜谱 * @return 原商品行或套餐组成商品行 */ private List toExportProductRows( @@ -1118,9 +1185,9 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { /** * 创建一个商品明细导出行。 * - * @param productName 商品展示名称 + * @param productName 商品展示名称 * @param productCount 有效商品数量 - * @param totalPrice 商品总价,单位为分 + * @param totalPrice 商品总价,单位为分 * @return 只填写商品明细列的导出行 */ private HardCanteenProductApptExcelVo toExportProductRow(String productName, long productCount, @@ -1165,7 +1232,7 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { /** * 查询当前日期涉及的每日菜谱并建立套餐展开缓存。 * - * @param products 当前日期的商品聚合记录 + * @param products 当前日期的商品聚合记录 * @param statisticsDate 当前业务发生日期 * @return 商户、日期、餐段和商品主键到每日菜谱的映射;远程查询失败的项不写入 */ @@ -1297,7 +1364,7 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { /** * 解析导出记录的业务日期。 * - * @param row 日期餐段聚合记录 + * @param row 日期餐段聚合记录 * @param fixedDate 当前按日批次的已知日期 * @return 可用业务日期;两者均为空或格式错误时返回空 */ @@ -1336,7 +1403,7 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { /** * 构造每日菜谱套餐缓存键。 * - * @param record 商品聚合记录 + * @param record 商品聚合记录 * @param statisticsDate 业务发生日期 * @return 商户、日期、餐段和商品主键组成的稳定键 */ @@ -1362,7 +1429,7 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { /** * 解析餐段名称,字典缺失时保留协议编码。 * - * @param hourType 餐段协议编码 + * @param hourType 餐段协议编码 * @param hourNames 餐段字典映射 * @return 餐段名称或原编码 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java new file mode 100644 index 00000000..a0da9b17 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java @@ -0,0 +1,1060 @@ +package com.bsd.pjcloud.hard.service.impl.order; + +import cn.hutool.core.date.DatePattern; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.io.resource.ResourceUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.constant.user.UserIdentityTypeEnum; +import com.bsd.pjcloud.admin.api.dto.UserDTO; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; +import com.bsd.pjcloud.admin.api.entity.SysPost; +import com.bsd.pjcloud.admin.api.feign.RemoteFileService; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.admin.api.vo.UserVO; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum; +import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate; +import com.bsd.pjcloud.common.security.util.SecurityUtils; +import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; +import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; +import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; +import com.bsd.pjcloud.hard.service.HardManagerService; +import com.bsd.pjcloud.hard.service.HardOrderExportService; +import com.bsd.pjcloud.hard.support.HardUserIdentitySupport; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.xssf.streaming.SXSSFSheet; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.beans.BeanUtils; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * 硬件订单导出服务实现。 + * + *

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

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

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

+ * + * @param response 当前 HTTP 下载响应 + * @param dto 订单页面当前筛选条件 + */ + @Override + public void export(HttpServletResponse response, HardOrderDto dto) { + HardOrderDto querySnapshot = prepareExportQuery(copyQuery(dto)); + Page firstPage = fetchExportPage(1L, ORDER_EXPORT_BATCH_SIZE, querySnapshot, true); + long total = validateExportTotal(firstPage.getTotal()); + response.setContentType(ORDER_EXPORT_CONTENT_TYPE); + writeExportWorkbook(querySnapshot, firstPage, total, null, file -> { + Files.copy(file, response.getOutputStream()); + response.flushBuffer(); + return null; + }); + } + + /** + * 在异步线程中生成订单工作簿并上传文件中心。 + * + * @param query 已冻结权限和筛选条件的订单查询 + * @param reporter 当前异步任务进度报告器 + * @return 文件中心下载地址和业务文件名 + */ + private AsyncTaskResult exportToFile(HardOrderDto query, AsyncTaskProgressReporter reporter) { + Page firstPage = fetchExportPage(1L, ORDER_EXPORT_BATCH_SIZE, query, true); + long total = validateExportTotal(firstPage.getTotal()); + int progressTotal = BatchExcelExportTemplate.calculateProgressTotal(total, ORDER_EXPORT_BATCH_SIZE, + ORDER_EXPORT_EXTRA_PROGRESS_STEPS); + reporter.updateTotal(progressTotal, "已查询 " + total + " 条订单,将分批生成文件"); + + String fileName = "订单记录_" + LocalDateTime.now().format(ORDER_EXPORT_FILE_TIME_FORMATTER) + ".xlsx"; + try { + String fileUrl = writeExportWorkbook(query, firstPage, total, reporter, + file -> uploadExportFile(file, fileName)); + reporter.advance("订单导出文件已上传,共 " + total + " 条"); + return AsyncTaskResult.file(fileUrl, fileName); + } catch (IllegalArgumentException | IllegalStateException exception) { + throw exception; + } catch (Exception exception) { + log.error("|{}|生成或上传文件失败,fileName:{}", LOG_TAG, fileName, exception); + throw new IllegalStateException("生成订单导出文件失败", exception); + } + } + + /** + * 使用公共模板按页查询订单、流式写入明细和合计,并消费最终 XLSX 临时文件。 + * + * @param query 已冻结的订单查询条件 + * @param firstPage 已用于取得总数的第一页,避免重复查询首批订单 + * @param total 导出开始时按相同条件统计的订单总数 + * @param reporter 异步任务进度报告器;同步兼容导出时为空 + * @param fileConsumer 完整临时文件消费回调 + * @param 文件消费结果类型 + * @return 文件消费回调返回的结果 + */ + private R writeExportWorkbook(HardOrderDto query, Page firstPage, long total, + AsyncTaskProgressReporter reporter, + BatchExcelExportTemplate.FileConsumer fileConsumer) { + try { + return BatchExcelExportTemplate.builder(total) + .batchSize(ORDER_EXPORT_BATCH_SIZE) + .dataStartRowIndex(ORDER_DETAIL_START_ROW_INDEX) + .reservedTailRowCount(ORDER_RESERVED_TAIL_ROW_COUNT) + .temporaryFilePrefix("hard-order-export-") + .templateSupplier(() -> ResourceUtil.getStream(ORDER_EXPORT_TEMPLATE_PATH)) + .templateInitializer(workbook -> prepareTemplate(workbook, query)) + .pageFetcher((pageNumber, batchSize) -> pageNumber == 1L + ? safeRecords(firstPage) + : safeRecords(fetchExportPage(pageNumber, batchSize, query, false))) + .rowWriter((sheet, rowIndex, dataIndex, item, context) -> { + context.summary.add(item); + writeDetailRow(sheet, rowIndex, dataIndex, item, context); + }) + .footerWriter((sheet, rowIndex, processedTotal, context) -> + writeSummaryRow(sheet, rowIndex, context)) + .progressListener((processedTotal, expectedTotal) -> { + if (reporter != null) { + reporter.advance("已写入 " + processedTotal + " / " + expectedTotal + " 条订单"); + } + }) + .fileConsumer(fileConsumer) + .execute(); + } catch (IllegalArgumentException | IllegalStateException exception) { + throw exception; + } catch (Exception exception) { + throw new IllegalStateException("生成订单导出文件失败", exception); + } + } + + /** + * 查询指定订单导出分页,数据范围和列表页保持为商户部门权限。 + * + * @param current 从一开始的页码 + * @param size 单批最大订单数 + * @param query 已冻结的订单筛选条件 + * @param searchCount 是否执行总数查询,仅第一页需要 + * @return 在当前线程同步装配完整用户资料和商户名称的导出分页 + */ + private Page fetchExportPage(long current, long size, HardOrderDto query, boolean searchCount) { + Page page = new Page<>(current, size, searchCount); + Page orderPage = hardOrderMapper.scopePageList(page, query, createOrderDataScope()); + Page exportPage = new Page<>(current, size, orderPage.getTotal(), searchCount); + exportPage.setRecords(buildExportRows(orderPage.getRecords())); + return exportPage; + } + + /** + * 在导出任务线程中同步装配一个订单分片,避免列表服务的公共线程池丢失 Feign 登录凭证。 + * + * @param orders 当前订单分片,最多为订单导出批大小 + * @return 保持订单原始顺序并携带完整用户资料、商户名称的导出行 + */ + private List buildExportRows(List orders) { + if (orders == null || orders.isEmpty()) { + return List.of(); + } + List userIds = orders.stream().map(HardOrderVo::getUserId).filter(Objects::nonNull).distinct().toList(); + List merchantIds = orders.stream().map(HardOrderVo::getMerchantId).filter(Objects::nonNull).distinct().toList(); + Map userMap = fetchExportUserMap(userIds); + Map merchantNameMap = fetchMerchantNameMap(merchantIds); + return orders.stream().map(order -> new OrderExportRow(order, userMap.get(order.getUserId()), + StrUtil.blankToDefault(merchantNameMap.get(order.getMerchantId()), order.getMerchantName()))) + .toList(); + } + + /** + * 按当前订单分片的用户主键查询完整用户导出资料。 + * + * @param userIds 当前订单分片去重后的 UPMS 用户主键 + * @return 以用户主键索引的完整用户资料;订单未关联用户时返回空映射 + */ + private Map fetchExportUserMap(List userIds) { + if (userIds.isEmpty()) { + return Map.of(); + } + UserDTO userQuery = new UserDTO(); + userQuery.setUserIds(userIds); + R> response = remoteUserService.getScopeExportUserPage(1L, USER_EXPORT_BATCH_SIZE, userQuery); + if (response == null || !response.isOk() || response.getData() == null) { + String message = response == null ? null : response.getMsg(); + throw new IllegalStateException(StrUtil.blankToDefault(message, "查询订单导出用户资料失败")); + } + List users = response.getData().getRecords(); + if (users == null || users.isEmpty()) { + return Map.of(); + } + return users.stream().filter(user -> user != null && user.getUserId() != null) + .collect(Collectors.toMap(UserVO::getUserId, user -> user, (left, right) -> left, + LinkedHashMap::new)); + } + + /** + * 批量查询订单商户名称并校验钱包服务业务响应。 + * + * @param merchantIds 当前订单分片去重后的商户主键 + * @return 商户主键到名称的映射;订单未关联系统商户时返回空映射 + */ + private Map fetchMerchantNameMap(List merchantIds) { + if (merchantIds.isEmpty()) { + return Map.of(); + } + R> response = remoteMchService.getByIds(merchantIds); + if (response == null || !response.isOk()) { + String message = response == null ? null : response.getMsg(); + throw new IllegalStateException(StrUtil.blankToDefault(message, "查询订单导出商户资料失败")); + } + if (response.getData() == null || response.getData().isEmpty()) { + return Map.of(); + } + return response.getData().stream().filter(merchant -> merchant != null && merchant.getId() != null + && StrUtil.isNotBlank(merchant.getName())) + .collect(Collectors.toMap(MchManageVo::getId, MchManageVo::getName, (left, right) -> left, + LinkedHashMap::new)); + } + + /** + * 创建订单页面使用的数据权限配置,以商户部门字段限制当前用户可见订单。 + * + * @return 独立的数据权限参数,供单次分页查询使用 + */ + private DataScope createOrderDataScope() { + DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL); + dataScope.setScopeDeptName("merchant_dept_id"); + return dataScope; + } + + /** + * 校验导出总数和单工作表容量。 + * + * @param total 第一页分页查询返回的订单总数 + * @return 已通过校验的订单总数 + */ + private long validateExportTotal(long total) { + if (total <= 0L) { + throw new IllegalStateException("没有符合当前条件的订单记录"); + } + if (total > MAX_ORDER_EXPORT_ROWS) { + throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 " + MAX_ORDER_EXPORT_ROWS + + " 条,请缩小查询范围后分批导出"); + } + return total; + } + + /** + * 获取分页记录并把空记录统一为不可变空列表。 + * + * @param page 订单查询分页结果 + * @return 当前页导出行;分页或记录为空时返回空列表 + */ + private List safeRecords(Page page) { + return page == null || page.getRecords() == null ? List.of() : page.getRecords(); + } + + /** + * 读取模板明细、合计样式并移除占位行,为 SXSSF 连续追加订单腾出空间。 + * + * @param workbook 由既有订单模板创建的可编辑工作簿 + * @param query 已冻结的订单筛选条件,用于生成标题时间范围 + * @return 明细和合计写入共享的样式及金额汇总上下文 + */ + private OrderExportTemplateContext prepareTemplate(XSSFWorkbook workbook, HardOrderDto query) { + Sheet sheet = workbook.getSheetAt(0); + Row titleRow = sheet.getRow(ORDER_TITLE_ROW_INDEX); + Row headerRow = sheet.getRow(ORDER_HEADER_ROW_INDEX); + Row detailTemplate = sheet.getRow(ORDER_DETAIL_TEMPLATE_ROW_INDEX); + Row summaryTemplate = sheet.getRow(ORDER_SUMMARY_TEMPLATE_ROW_INDEX); + if (titleRow == null || headerRow == null || detailTemplate == null || summaryTemplate == null) { + throw new IllegalStateException("订单导出模板缺少标题、表头、明细或合计占位行"); + } + extendTemplateColumns(sheet, titleRow, headerRow, detailTemplate, summaryTemplate); + CellStyle[] detailStyles = readRowStyles(detailTemplate); + CellStyle[] summaryStyles = readRowStyles(summaryTemplate); + short detailRowHeight = detailTemplate.getHeight(); + short summaryRowHeight = summaryTemplate.getHeight(); + + titleRow.getCell(0).setCellValue(buildTitle(query)); + for (int index = sheet.getNumMergedRegions() - 1; index >= 0; index--) { + CellRangeAddress mergedRegion = sheet.getMergedRegion(index); + if (mergedRegion.getFirstRow() == ORDER_TITLE_ROW_INDEX + || mergedRegion.getFirstRow() >= ORDER_SUMMARY_TEMPLATE_ROW_INDEX) { + sheet.removeMergedRegion(index); + } + } + sheet.addMergedRegion(new CellRangeAddress(ORDER_TITLE_ROW_INDEX, ORDER_TITLE_ROW_INDEX, + 0, ORDER_EXPORT_COLUMN_COUNT - 1)); + sheet.removeRow(summaryTemplate); + sheet.removeRow(detailTemplate); + return new OrderExportTemplateContext(detailStyles, summaryStyles, detailRowHeight, summaryRowHeight); + } + + /** + * 在内存中把既有十二列表格扩展为用户基础信息、身份信息和订单信息完整列。 + * + * @param sheet 订单模板工作表 + * @param titleRow 标题模板行 + * @param headerRow 表头模板行 + * @param detailTemplate 明细占位行 + * @param summaryTemplate 合计占位行 + */ + private void extendTemplateColumns(Sheet sheet, Row titleRow, Row headerRow, Row detailTemplate, + Row summaryTemplate) { + if (ORDER_DETAIL_STYLE_SOURCE_COLUMNS.length != ORDER_EXPORT_COLUMN_COUNT + || ORDER_SUMMARY_STYLE_SOURCE_COLUMNS.length != ORDER_EXPORT_COLUMN_COUNT + || ORDER_EXPORT_COLUMN_WIDTHS.length != ORDER_EXPORT_COLUMN_COUNT) { + throw new IllegalStateException("订单导出列配置数量不一致"); + } + Cell titleTemplateCell = titleRow.getCell(0); + Cell headerTemplateCell = headerRow.getCell(0); + if (titleTemplateCell == null || headerTemplateCell == null) { + throw new IllegalStateException("订单导出模板缺少标题或表头样式"); + } + CellStyle titleStyle = titleTemplateCell.getCellStyle(); + CellStyle headerStyle = headerTemplateCell.getCellStyle(); + CellStyle[] detailSourceStyles = readOriginalTemplateStyles(detailTemplate); + CellStyle[] summarySourceStyles = readOriginalTemplateStyles(summaryTemplate); + for (int column = 0; column < ORDER_EXPORT_COLUMN_COUNT; column++) { + Cell titleCell = getOrCreateCell(titleRow, column); + titleCell.setCellStyle(titleStyle); + Cell headerCell = getOrCreateCell(headerRow, column); + headerCell.setCellStyle(headerStyle); + headerCell.setCellValue(ORDER_EXPORT_HEADERS[column]); + getOrCreateCell(detailTemplate, column) + .setCellStyle(detailSourceStyles[ORDER_DETAIL_STYLE_SOURCE_COLUMNS[column]]); + getOrCreateCell(summaryTemplate, column) + .setCellStyle(summarySourceStyles[ORDER_SUMMARY_STYLE_SOURCE_COLUMNS[column]]); + sheet.setColumnWidth(column, ORDER_EXPORT_COLUMN_WIDTHS[column] * 256); + } + } + + /** + * 获取模板行单元格,不存在时按指定列创建。 + * + * @param row 模板行 + * @param column Excel 零基列号 + * @return 已存在或新建的单元格 + */ + private Cell getOrCreateCell(Row row, int column) { + Cell cell = row.getCell(column); + return cell == null ? row.createCell(column) : cell; + } + + /** + * 在扩展列之前读取原十二列表格的样式快照,避免映射过程中覆盖后续样式来源。 + * + * @param row 原模板明细或合计占位行 + * @return 按原 A-L 列顺序排列的样式快照 + */ + private CellStyle[] readOriginalTemplateStyles(Row row) { + CellStyle[] styles = new CellStyle[ORDER_ORIGINAL_TEMPLATE_COLUMN_COUNT]; + for (int column = 0; column < ORDER_ORIGINAL_TEMPLATE_COLUMN_COUNT; column++) { + Cell cell = row.getCell(column); + if (cell == null) { + throw new IllegalStateException("订单导出模板缺少第 " + (column + 1) + " 列样式"); + } + styles[column] = cell.getCellStyle(); + } + return styles; + } + + /** + * 读取模板行每一列的样式,确保新增行延续原模板的字体、边框和对齐方式。 + * + * @param row 明细或合计模板行 + * @return 按 A-U 列顺序排列的样式数组 + */ + private CellStyle[] readRowStyles(Row row) { + CellStyle[] styles = new CellStyle[ORDER_EXPORT_COLUMN_COUNT]; + for (int column = 0; column < ORDER_EXPORT_COLUMN_COUNT; column++) { + Cell cell = row.getCell(column); + if (cell != null) { + styles[column] = cell.getCellStyle(); + } + } + return styles; + } + + /** + * 把一条订单写入模板明细行,并在写入边界完成业务编码和金额单位转换。 + * + * @param sheet 订单导出工作表 + * @param rowIndex 当前 Excel 零基行号 + * @param dataIndex 当前订单在全部导出数据中的零基序号 + * @param item 当前订单及其完整用户、商户导出资料 + * @param context 模板样式和跨批次金额汇总上下文 + */ + private void writeDetailRow(SXSSFSheet sheet, int rowIndex, long dataIndex, OrderExportRow item, + OrderExportTemplateContext context) { + HardOrderVo order = item.order; + UserVO user = item.user; + Row row = sheet.createRow(rowIndex); + row.setHeight(context.detailRowHeight); + setCell(row, 0, context.detailStyles[0], dataIndex + 1L); + setCell(row, 1, context.detailStyles[1], item.merchantName); + setCell(row, 2, context.detailStyles[2], formatUserId(order.getUserId())); + setCell(row, 3, context.detailStyles[3], resolveUserName(user, order)); + setCell(row, 4, context.detailStyles[4], user == null ? null : user.getEmail()); + setCell(row, 5, context.detailStyles[5], user == null ? order.getUserDeptName() + : StrUtil.blankToDefault(user.getDeptName(), order.getUserDeptName())); + setCell(row, 6, context.detailStyles[6], user == null ? null : joinPostNames(user.getPostList())); + setCell(row, 7, context.detailStyles[7], user == null ? order.getUserMobile() + : StrUtil.blankToDefault(user.getPhone(), order.getUserMobile())); + setCell(row, 8, context.detailStyles[8], user == null ? null : joinIdentityNames(user.getIdentityTypes())); + setCell(row, 9, context.detailStyles[9], user == null || user.getStudentInfo() == null + ? null : user.getStudentInfo().getStudentNo()); + setCell(row, 10, context.detailStyles[10], user == null || user.getStudentInfo() == null + ? null : user.getStudentInfo().getSchoolName()); + setCell(row, 11, context.detailStyles[11], user == null || user.getStudentInfo() == null + ? null : user.getStudentInfo().getClassName()); + setCell(row, 12, context.detailStyles[12], user == null || user.getStudentInfo() == null + ? null : user.getStudentInfo().getGuardianPhones()); + setCell(row, 13, context.detailStyles[13], user == null || user.getTeacherInfo() == null + ? null : user.getTeacherInfo().getTeacherNo()); + setCell(row, 14, context.detailStyles[14], order.getProductDesc()); + setCell(row, 15, context.detailStyles[15], resolvePayType(order.getPayType())); + setCell(row, 16, context.detailStyles[16], resolveHourType(order.getHourType())); + setCell(row, 17, context.detailStyles[17], formatPrice(order.getTotalPrice())); + setCell(row, 18, context.detailStyles[18], formatPrice(order.getExtraPrice())); + setCell(row, 19, context.detailStyles[19], formatPrice(order.getPayPrice())); + setCell(row, 20, context.detailStyles[20], formatDateTime(order.getPayTime())); + } + + /** + * 在所有订单明细之后写入金额合计,并合并金额列之前的说明区域。 + * + * @param sheet 订单导出工作表 + * @param rowIndex 合计行的 Excel 零基行号 + * @param context 模板样式和跨批次金额汇总上下文 + */ + private void writeSummaryRow(SXSSFSheet sheet, int rowIndex, OrderExportTemplateContext context) { + Row row = sheet.createRow(rowIndex); + row.setHeight(context.summaryRowHeight); + for (int column = 0; column < ORDER_EXPORT_COLUMN_COUNT; column++) { + setCell(row, column, context.summaryStyles[column], null); + } + row.getCell(0).setCellValue("合计"); + row.getCell(ORDER_TOTAL_PRICE_COLUMN_INDEX) + .setCellValue(context.summary.formatPrice(context.summary.totalPriceSum).doubleValue()); + row.getCell(ORDER_EXTRA_PRICE_COLUMN_INDEX) + .setCellValue(context.summary.formatPrice(context.summary.extraPriceSum).doubleValue()); + row.getCell(ORDER_PAY_PRICE_COLUMN_INDEX) + .setCellValue(context.summary.formatPrice(context.summary.payPriceSum).doubleValue()); + sheet.addMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, + ORDER_SUMMARY_MERGE_LAST_COLUMN_INDEX)); + } + + /** + * 将用户主键按文本写入 Excel,避免雪花主键超过双精度整数范围后发生尾数丢失。 + * + * @param userId UPMS 用户主键,允许为空 + * @return 十进制文本;输入为空时返回空 + */ + private String formatUserId(Long userId) { + return userId == null ? null : userId.toString(); + } + + /** + * 依次按姓名、昵称、登录账号和订单历史展示名确定导出姓名。 + * + * @param user UPMS 返回的完整用户资料,历史订单用户不存在时为空 + * @param order 当前订单,用于用户资料缺失时兼容历史展示字段 + * @return 用户导出姓名;所有来源均为空时返回空文本 + */ + private String resolveUserName(UserVO user, HardOrderVo order) { + if (user == null) { + return StrUtil.blankToDefault(order.getUsername(), ""); + } + if (StrUtil.isNotBlank(user.getName())) { + return user.getName(); + } + if (StrUtil.isNotBlank(user.getNickname())) { + return user.getNickname(); + } + return StrUtil.blankToDefault(user.getUsername(), StrUtil.blankToDefault(order.getUsername(), "")); + } + + /** + * 拼接用户岗位名称,多个岗位使用中文逗号分隔。 + * + * @param posts UPMS 完整用户资料中的岗位列表 + * @return 去空后的岗位名称;没有岗位时返回空文本 + */ + private String joinPostNames(List posts) { + if (posts == null || posts.isEmpty()) { + return ""; + } + return posts.stream().filter(Objects::nonNull).map(SysPost::getPostName) + .filter(StrUtil::isNotBlank).collect(Collectors.joining(",")); + } + + /** + * 将用户身份协议编码转换并拼接为中文名称,未知历史编码保留原值。 + * + * @param identityTypes UPMS 返回的用户身份协议编码 + * @return 以中文逗号分隔的身份名称;没有身份时返回空文本 + */ + private String joinIdentityNames(List identityTypes) { + if (identityTypes == null || identityTypes.isEmpty()) { + return ""; + } + return identityTypes.stream().map(identityType -> { + UserIdentityTypeEnum identity = UserIdentityTypeEnum.valueOfByType(identityType); + return identity == null ? identityType : identity.getDescription(); + }).filter(StrUtil::isNotBlank).collect(Collectors.joining(",")); + } + + /** + * 为单元格设置模板样式和值,空值仍创建单元格以保留完整边框。 + * + * @param row 目标行 + * @param column 目标列零基下标 + * @param style 模板样式,允许为空 + * @param value 单元格展示值,数字保持数字类型,其余按文本写入 + */ + private void setCell(Row row, int column, CellStyle style, Object value) { + Cell cell = row.createCell(column); + if (style != null) { + cell.setCellStyle(style); + } + if (value instanceof Number number) { + cell.setCellValue(number.doubleValue()); + } else if (value != null) { + cell.setCellValue(value.toString()); + } + } + + /** + * 生成订单导出标题;仅支付时间范围沿用旧模板的标题展示规则。 + * + * @param query 订单筛选条件 + * @return 包含支付时间范围的订单交易汇总标题,未选择范围时返回固定标题 + */ + private String buildTitle(HardOrderDto query) { + if (query.getPayTimeList() != null && query.getPayTimeList().length == 2 + && query.getPayTimeList()[0] != null && query.getPayTimeList()[1] != null) { + return LocalDateTimeUtil.format(query.getPayTimeList()[0], DatePattern.NORM_DATETIME_PATTERN) + + " 到 " + + LocalDateTimeUtil.format(query.getPayTimeList()[1], DatePattern.NORM_DATETIME_PATTERN) + + "订单交易汇总"; + } + return "订单交易汇总"; + } + + /** + * 将支付方式协议编码转换为现有枚举中文描述,未知编码原样保留以兼容历史数据。 + * + * @param payType 订单支付方式协议编码 + * @return 支付方式中文描述或原始编码 + */ + private String resolvePayType(String payType) { + if (StrUtil.isBlank(payType)) { + return payType; + } + HardPayTypeEnum value = HardPayTypeEnum.valueOfByType(payType); + return value == null ? payType : value.getDescription(); + } + + /** + * 将餐段协议编码转换为现有枚举中文描述,未知编码沿用旧导出规则输出空文本。 + * + * @param hourType 用餐时间段协议编码 + * @return 餐段中文描述、空值或空文本 + */ + private String resolveHourType(String hourType) { + if (StrUtil.isBlank(hourType)) { + return hourType; + } + HardHourTypeEnum value = HardHourTypeEnum.valueOfByType(hourType); + return value == null ? "" : value.getDescription(); + } + + /** + * 将订单金额从分转换为保留两位小数的元金额。 + * + * @param amount 订单金额,单位为分;空值表示模板单元格留空 + * @return 单位为元的金额;输入为空时返回空 + */ + private BigDecimal formatPrice(Number amount) { + return amount == null ? null : BigDecimal.valueOf(amount.longValue()) + .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + } + + /** + * 将订单支付时间转换为模板使用的标准日期时间文本。 + * + * @param dateTime 订单支付时间,允许为空 + * @return yyyy-MM-dd HH:mm:ss 文本;输入为空时返回空 + */ + private String formatDateTime(LocalDateTime dateTime) { + return dateTime == null ? null : LocalDateTimeUtil.format(dateTime, DatePattern.NORM_DATETIME_PATTERN); + } + + /** + * 上传完整订单工作簿并校验文件中心业务响应。 + * + * @param file 已关闭并可读取的完整 XLSX 临时文件 + * @param fileName 用户下载时使用的文件名 + * @return 文件中心提供的下载地址 + * @throws Exception 文件读取或远程上传失败时抛出 + */ + private String uploadExportFile(java.nio.file.Path file, String fileName) throws Exception { + MultipartFile exportFile = new MockMultipartFile("file", encodeUploadOriginalFilename(fileName), + ORDER_EXPORT_CONTENT_TYPE, Files.readAllBytes(file)); + R uploadResult = remoteFileService.innerUpload(exportFile, ORDER_EXPORT_FILE_DIR, null, + ORDER_EXPORT_FILE_TYPE); + if (uploadResult == null || !uploadResult.isOk() + || !(uploadResult.getData() instanceof Map resultData)) { + throw new IllegalStateException("文件中心未返回有效的订单导出结果"); + } + Object resultUrl = resultData.get("url"); + if (resultUrl == null || StrUtil.isBlank(resultUrl.toString())) { + throw new IllegalStateException("文件中心未返回订单导出下载地址"); + } + return resultUrl.toString(); + } + + /** + * 按文件服务现有约定转换中文原始文件名编码。 + * + * @param fileName 用户看到的 UTF-8 文件名 + * @return 文件中心上传接口使用的原始文件名 + */ + private String encodeUploadOriginalFilename(String fileName) { + return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); + } + + /** + * 冻结当前用户允许查看的设备类型、身份用户范围和单商户的子商户范围。 + * + * @param query 已复制、由当前任务独占的订单查询 + * @return 已补充设备、身份用户和商户权限范围的同一查询对象 + */ + private HardOrderDto prepareExportQuery(HardOrderDto query) { + CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(SecurityUtils.getUser()); + query.setMchTypeCodes(hardTypeInfo == null || hardTypeInfo.getHardTypeList() == null + ? List.of() : new ArrayList<>(hardTypeInfo.getHardTypeList())); + + applyIdentityQuery(query); + + List mchIds = query.getMchIds(); + if (mchIds != null && mchIds.size() == 1) { + Long parentMchId = mchIds.get(0); + R> response = remoteMchService.getByParentId(parentMchId); + if (response == null || !response.isOk()) { + throw new IllegalStateException("查询导出子商户范围失败"); + } + List expandedMchIds = new ArrayList<>(); + expandedMchIds.add(parentMchId); + if (response.getData() != null) { + response.getData().stream().map(MchManageVo::getId).filter(java.util.Objects::nonNull) + .forEach(expandedMchIds::add); + } + query.setMchIds(expandedMchIds.stream().distinct().toList()); + } + return query; + } + + /** + * 将身份筛选一次性转换为用户主键范围,后续所有订单分片复用同一条件。 + * + * @param query 当前导出任务独占的订单筛选条件,方法会清除已转换的身份字段 + */ + private void applyIdentityQuery(HardOrderDto query) { + if (!userIdentitySupport.hasIdentityQuery(query)) { + return; + } + query.setUserIds(userIdentitySupport.findIdentityUserIds(query, query.getUserId(), query.getUserIds())); + query.setUserId(null); + query.setIdentityTypes(null); + query.setStudentInfo(null); + query.setTeacherInfo(null); + query.setHasFace(null); + } + + /** + * 深复制订单导出筛选条件,避免请求对象中的集合或数组在后台任务开始前发生变化。 + * + * @param source Controller 接收的订单筛选条件,允许为空 + * @return 后台导出任务独占的筛选条件副本 + */ + private HardOrderDto copyQuery(HardOrderDto source) { + HardOrderDto target = new HardOrderDto(); + if (source == null) { + return target; + } + BeanUtils.copyProperties(source, target); + target.setDeptIds(copyList(source.getDeptIds())); + target.setUserIds(copyList(source.getUserIds())); + target.setUserDeptIds(copyList(source.getUserDeptIds())); + target.setMerchantDeptIds(copyList(source.getMerchantDeptIds())); + target.setOrderIds(copyList(source.getOrderIds())); + target.setHardIds(copyList(source.getHardIds())); + target.setMchIds(copyList(source.getMchIds())); + target.setMchTypeCodes(copyList(source.getMchTypeCodes())); + target.setOrderStatusCodes(copyList(source.getOrderStatusCodes())); + target.setOrderTypeCodes(copyList(source.getOrderTypeCodes())); + target.setOrderPayTypeCodes(copyList(source.getOrderPayTypeCodes())); + target.setExcludeOrderPayTypeCodes(copyList(source.getExcludeOrderPayTypeCodes())); + target.setHardTypeCodes(copyList(source.getHardTypeCodes())); + target.setRefundStatusCodes(copyList(source.getRefundStatusCodes())); + target.setSaleSourceCodes(copyList(source.getSaleSourceCodes())); + target.setSaleStatusCodes(copyList(source.getSaleStatusCodes())); + target.setScopeDeptIds(copyList(source.getScopeDeptIds())); + target.setAppWardUserIds(copyList(source.getAppWardUserIds())); + target.setIdentityTypes(copyList(source.getIdentityTypes())); + target.setCreateTimeList(cloneArray(source.getCreateTimeList())); + target.setUpdateTimeList(cloneArray(source.getUpdateTimeList())); + target.setVerificationTimeList(cloneArray(source.getVerificationTimeList())); + target.setPayTimeList(cloneArray(source.getPayTimeList())); + target.setStatisticsDateTimeList(cloneArray(source.getStatisticsDateTimeList())); + target.setRefundTimeList(cloneArray(source.getRefundTimeList())); + target.setApptUseDateTimeList(cloneArray(source.getApptUseDateTimeList())); + target.setStudentInfo(copyStudentInfo(source.getStudentInfo())); + target.setTeacherInfo(copyTeacherInfo(source.getTeacherInfo())); + return target; + } + + /** + * 复制可变列表,保留空值语义并隔离调用方后续修改。 + * + * @param source 原始列表,允许为空 + * @param 列表元素类型 + * @return 独立列表副本;输入为空时返回空 + */ + private List copyList(List source) { + return source == null ? null : new ArrayList<>(source); + } + + /** + * 复制日期时间范围数组,保留空值语义。 + * + * @param source 原始日期时间范围,允许为空 + * @return 独立数组副本;输入为空时返回空 + */ + private LocalDateTime[] cloneArray(LocalDateTime[] source) { + return source == null ? null : source.clone(); + } + + /** + * 复制学生身份扩展筛选条件,避免异步任务共享请求对象。 + * + * @param source 学生身份筛选条件,允许为空 + * @return 独立学生筛选副本;输入为空时返回空 + */ + private SysUserStudentDto copyStudentInfo(SysUserStudentDto source) { + if (source == null) { + return null; + } + SysUserStudentDto target = new SysUserStudentDto(); + BeanUtils.copyProperties(source, target); + return target; + } + + /** + * 复制老师身份扩展筛选条件,避免异步任务共享请求对象。 + * + * @param source 老师身份筛选条件,允许为空 + * @return 独立老师筛选副本;输入为空时返回空 + */ + private SysUserTeacherDto copyTeacherInfo(SysUserTeacherDto source) { + if (source == null) { + return null; + } + SysUserTeacherDto target = new SysUserTeacherDto(); + BeanUtils.copyProperties(source, target); + return target; + } + + /** + * 订单导出模板样式和跨批次金额汇总上下文。 + */ + private static final class OrderExportTemplateContext { + + /** 明细行 A-U 列的模板样式。 */ + private final CellStyle[] detailStyles; + + /** 合计行 A-U 列的模板样式。 */ + private final CellStyle[] summaryStyles; + + /** 明细模板行高度,单位为 POI twip。 */ + private final short detailRowHeight; + + /** 合计模板行高度,单位为 POI twip。 */ + private final short summaryRowHeight; + + /** 跨分页累计的订单金额汇总。 */ + private final OrderExportSummary summary = new OrderExportSummary(); + + /** + * 创建订单导出模板上下文。 + * + * @param detailStyles 明细行 A-U 列样式 + * @param summaryStyles 合计行 A-U 列样式 + * @param detailRowHeight 明细行高度 + * @param summaryRowHeight 合计行高度 + */ + private OrderExportTemplateContext(CellStyle[] detailStyles, CellStyle[] summaryStyles, + short detailRowHeight, short summaryRowHeight) { + this.detailStyles = detailStyles; + this.summaryStyles = summaryStyles; + this.detailRowHeight = detailRowHeight; + this.summaryRowHeight = summaryRowHeight; + } + } + + /** + * 单条订单导出行,组合数据库订单、UPMS 完整用户资料和钱包商户名称。 + */ + private static final class OrderExportRow { + + /** 当前数据权限和筛选条件命中的订单记录。 */ + private final HardOrderVo order; + + /** UPMS 返回的完整用户导出资料;历史用户不存在或不可见时为空。 */ + private final UserVO user; + + /** 钱包服务返回的商户名称;历史商户不存在时为空。 */ + private final String merchantName; + + /** + * 创建订单导出行。 + * + * @param order 当前订单记录 + * @param user 与订单用户主键匹配的完整用户资料 + * @param merchantName 与订单商户主键匹配的商户名称 + */ + private OrderExportRow(HardOrderVo order, UserVO user, String merchantName) { + this.order = order; + this.user = user; + this.merchantName = merchantName; + } + } + + /** + * 订单导出跨分页金额汇总器,所有金额均按数据库分单位累计。 + */ + private static final class OrderExportSummary { + + /** 商品总价累计值,单位为分。 */ + private BigDecimal totalPriceSum = BigDecimal.ZERO; + + /** 额外支付累计值,单位为分。 */ + private BigDecimal extraPriceSum = BigDecimal.ZERO; + + /** 实际支付累计值,单位为分。 */ + private BigDecimal payPriceSum = BigDecimal.ZERO; + + /** + * 累计当前订单的三个金额字段,历史空值按零分处理。 + * + * @param item 当前订单及其导出关联资料 + */ + private void add(OrderExportRow item) { + totalPriceSum = addAmount(totalPriceSum, item.order.getTotalPrice()); + extraPriceSum = addAmount(extraPriceSum, item.order.getExtraPrice()); + payPriceSum = addAmount(payPriceSum, item.order.getPayPrice()); + } + + /** + * 将非空分金额加入当前累计值。 + * + * @param current 当前累计金额,单位为分 + * @param amount 待累计金额,单位为分;允许为空 + * @return 累加后的分金额 + */ + private BigDecimal addAmount(BigDecimal current, Number amount) { + return amount == null ? current : current.add(BigDecimal.valueOf(amount.longValue())); + } + + /** + * 将累计的分金额转换为两位小数元金额。 + * + * @param amount 累计金额,单位为分 + * @return 单位为元的两位小数金额 + */ + private BigDecimal formatPrice(BigDecimal amount) { + return amount.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java index db9b1538..589566fa 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java @@ -1909,16 +1909,21 @@ public class HardOrderServiceImpl extends ServiceImpl= 1 && config.getExpiredReserveRefundDelayHours() <= 168 - && reservation.usageEndDateTime() != null + && order.getExpiredRefundUsageEndTime() != null && config.getId() != null) { order.setExpiredRefundConfigId(config.getId()); - order.setExpiredRefundUsageEndTime(reservation.usageEndDateTime()); - order.setExpiredRefundDeadline(reservation.usageEndDateTime().plusHours(config.getExpiredReserveRefundDelayHours())); + order.setExpiredRefundDeadline(order.getExpiredRefundUsageEndTime().plusHours(config.getExpiredReserveRefundDelayHours())); order.setExpiredRefundRestoreInventory(Objects.equals(config.getExpiredReserveRefundRestoreInventory(), YesNoEnum.YES.getCode()) ? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode()); } @@ -2468,6 +2472,8 @@ public class HardOrderServiceImpl extends ServiceImpl order by t.trigger_time asc, t.id asc + + + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml index f10256ab..f080f722 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml @@ -161,6 +161,12 @@ #{v}
+ + and o.user_id in + + #{v} + + @@ -928,7 +934,7 @@ group by a.product_id - order by SUM(a.product_count) desc + order by a.product_id desc, o.order_type asc, o.hour_type asc, SUM(a.product_count) desc @@ -1075,6 +1081,12 @@ #{v} + + and o.user_id in + + #{v} + + and o.pay_time between #{dto.payTimeList[0]} and #{dto.payTimeList[1]} @@ -1182,6 +1194,12 @@ #{v} + + and o.user_id in + + #{v} + + and o.order_status in diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml index 652683b2..0afa5b1f 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml @@ -167,6 +167,13 @@ #{v} + + + and a.id in + + #{v} + + and a.merchant_id in @@ -362,11 +369,15 @@ order by a.verification_time desc, a.id desc + + order by a.appt_use_date desc, a.hour_type asc, a.id desc + order by a.update_time desc, a.id desc - order by a.create_time desc, a.user_id desc + + order by a.create_time desc, a.user_id desc, a.id desc @@ -388,6 +399,9 @@ order by a.verification_time desc, a.id desc + + order by a.appt_use_date desc, a.hour_type asc, a.id desc + order by a.update_time desc, a.id desc diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java index b590483d..c2551929 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java @@ -58,7 +58,7 @@ class HardApptReservationServiceImplTest { appt, APPT_DATE, LocalDateTime.of(2026, 7, 21, 10, 0, 0, 1)).available()); } - /** 动态预约直接快照已校验餐段的实际结束时间。 */ + /** 动态预约固化用餐日当天 23:59:59 作为核销有效期结束时间。 */ @Test void dynamicReservationUsesValidatedSegmentEndTime() { HardApptEntity dynamic = timeWindowAppt(); @@ -71,16 +71,14 @@ class HardApptReservationServiceImplTest { .validateReservation(10L, 1L, null, "1", APPT_DATE, null, LocalDateTime.of(2026, 7, 20, 18, 0)); - assertEquals(LocalDateTime.of(2026, 7, 21, 10, 0), validation.usageEndDateTime()); + assertEquals(LocalDateTime.of(2026, 7, 21, 23, 59, 59), validation.usageEndDateTime()); } - /** 周期预约按有效 hard_appt 餐段解析结束时间,并支持结束时间落到用餐日次日。 */ + /** 周期预约固化用餐日当天 23:59:59,不受当前 hard_appt 餐段配置影响。 */ @Test - void weeklyReservationResolvesCrossDaySegmentEndTime() { + void weeklyReservationUsesUseDateEndOfDay() { HardApptEntity weekly = weeklyAppt(1L, 10L); weekly.setHourType("1"); - weekly.setEndTimeType(HardApptTimeTypeEnum.TOMORROW.getType()); - weekly.setEndTime(LocalTime.of(2, 0)); HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0), 7, LocalTime.of(22, 0), 2, 7, "1"); @@ -88,20 +86,16 @@ class HardApptReservationServiceImplTest { .validateReservation(10L, null, 100L, "1", LocalDate.of(2026, 7, 28), null, LocalDateTime.of(2026, 7, 24, 18, 0)); - assertEquals(LocalDateTime.of(2026, 7, 29, 2, 0), validation.usageEndDateTime()); + assertEquals(LocalDateTime.of(2026, 7, 28, 23, 59, 59), validation.usageEndDateTime()); } - /** 周期餐段无法唯一匹配时不生成结束时间,避免按错误规则自动退款。 */ + /** 周期预约即使存在重复餐段配置,也按订单用餐日固化全天核销有效期。 */ @Test - void weeklyReservationOmitsUsageEndTimeWhenSegmentIsAmbiguous() { + void weeklyReservationIgnoresDuplicateSegmentDefinitions() { HardApptEntity weekly = weeklyAppt(1L, 10L); weekly.setHourType("1"); - weekly.setEndTimeType(HardApptTimeTypeEnum.TODAY.getType()); - weekly.setEndTime(LocalTime.of(13, 0)); HardApptEntity duplicate = weeklyAppt(2L, 10L); duplicate.setHourType("1"); - duplicate.setEndTimeType(HardApptTimeTypeEnum.TODAY.getType()); - duplicate.setEndTime(LocalTime.of(14, 0)); HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0), 7, LocalTime.of(22, 0), 2, 7, "1"); @@ -109,7 +103,7 @@ class HardApptReservationServiceImplTest { .validateReservation(10L, null, 100L, "1", LocalDate.of(2026, 7, 28), null, LocalDateTime.of(2026, 7, 24, 18, 0)); - assertNull(validation.usageEndDateTime()); + assertEquals(LocalDateTime.of(2026, 7, 28, 23, 59, 59), validation.usageEndDateTime()); } @Test diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java index c3b5c2c4..4c5a3413 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java @@ -4,11 +4,15 @@ import com.baomidou.lock.LockTemplate; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskTypeEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum; import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity; import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleMapper; import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundAttemptLogMapper; import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundTaskMapper; @@ -28,6 +32,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.ObjectProvider; import org.springframework.test.util.ReflectionTestUtils; +import java.time.LocalDate; import java.time.LocalDateTime; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -78,7 +83,6 @@ class HardAutoRefundTaskServiceImplTest { /** 订单 Mapper Mock,用于候选订单和到期退款流程的订单锁查询。 */ @Mock private HardOrderMapper orderMapper; - /** 被测自动退款任务服务。 */ private HardAutoRefundTaskServiceImpl service; @@ -201,6 +205,67 @@ class HardAutoRefundTaskServiceImplTest { assertThrows(RuntimeException.class, () -> spyService.manualExpiredRefund(1L, 99L)); } + /** 新订单专用核销结束时间必须优先于历史兜底,保证重扫始终使用下单时事实。 */ + @Test + void expiredRescanShouldPreferDedicatedOrderSnapshot() { + HardOrderEntity order = HardOrderEntity.builder() + .expiredRefundUsageEndTime(LocalDateTime.of(2026, 8, 23, 13, 0)) + .autoRefundDeadline(LocalDateTime.of(2026, 8, 23, 22, 0)) + .apptUseDate(LocalDate.of(2026, 8, 23)) + .build(); + + LocalDateTime result = ReflectionTestUtils.invokeMethod(service, + "resolveExpiredRefundUsageEndTimeSnapshot", order); + + assertEquals(LocalDateTime.of(2026, 8, 23, 13, 0), result); + } + + /** 历史订单没有专用字段时,只使用订单预约日期兜底到当天 23:59:59。 */ + @Test + void expiredRescanShouldUseApptDateEndOfDayWhenDedicatedSnapshotMissing() { + HardOrderEntity order = HardOrderEntity.builder() + .autoRefundDeadline(LocalDateTime.of(2026, 8, 23, 22, 0)) + .apptUseDate(LocalDate.of(2026, 8, 23)) + .build(); + + LocalDateTime result = ReflectionTestUtils.invokeMethod(service, + "resolveExpiredRefundUsageEndTimeSnapshot", order); + + assertEquals(LocalDateTime.of(2026, 8, 23, 23, 59, 59), result); + } + + /** 开关关闭重扫只创建暂停任务,不会把历史订单推进到资金执行状态。 */ + @Test + void disabledExpiredRescanShouldCreateSwitchDisabledTask() { + HardOrderEntity order = HardOrderEntity.builder() + .id(2091499615852482561L) + .merchantId(2079103103881199617L) + .orderType(HardOrderTypeEnum.RESERVE.getType()) + .orderStatus(HardOrderStatusEnum.PAY.getType()) + .payType(HardPayTypeEnum.WALLET_DEFAULT.getType()) + .payPrice(4210) + .apptUseDate(LocalDate.of(2026, 8, 23)) + .build(); + MchAutoRefundConfigVo config = new MchAutoRefundConfigVo(); + config.setId(2088157133475426306L); + config.setMchId(2079103103881199617L); + config.setOrderType(HardOrderTypeEnum.RESERVE.getType()); + LocalDateTime triggerTime = LocalDateTime.of(2026, 8, 24, 0, 59, 59); + when(orderMapper.selectByIdForUpdate(order.getId())).thenReturn(order); + when(afterSaleMapper.selectCount(any())).thenReturn(0L); + when(taskMapper.selectOne(any())).thenReturn(null); + when(taskMapper.insert(any(HardAutoRefundTaskEntity.class))).thenReturn(1); + ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(HardAutoRefundTaskEntity.class); + + int result = service.syncExpiredRescanTask(order, config, triggerTime, YesNoEnum.NO.getCode(), false); + + assertEquals(1, result); + verify(taskMapper).insert(taskCaptor.capture()); + assertEquals(HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus(), taskCaptor.getValue().getStatus()); + assertEquals(triggerTime, taskCaptor.getValue().getTriggerTime()); + assertNull(taskCaptor.getValue().getNextRetryTime()); + } + /** * 构造固定订单和售后单的自动退款任务。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java index e2f3c14c..e60e8ce9 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java @@ -183,16 +183,36 @@ class HardOrderServiceImplVerificationTest { config.setExpiredReserveRefundRestoreInventory("1"); HardApptReservationService.ReservationValidation validation = new HardApptReservationService.ReservationValidation( null, "1", LocalDate.of(2026, 8, 23), 9L, - LocalDateTime.of(2026, 8, 22, 22, 0), LocalDateTime.of(2026, 8, 23, 13, 0)); + LocalDateTime.of(2026, 8, 22, 22, 0), LocalDateTime.of(2026, 8, 23, 23, 59, 59)); ReflectionTestUtils.invokeMethod(service, "applyDailyMenuAutoRefundSnapshot", order, validation, config); assertNull(order.getAutoRefundConfigId()); assertEquals(7L, order.getExpiredRefundConfigId()); - assertEquals(LocalDateTime.of(2026, 8, 23, 15, 0), order.getExpiredRefundDeadline()); + assertEquals(LocalDateTime.of(2026, 8, 24, 1, 59, 59), order.getExpiredRefundDeadline()); assertEquals("1", order.getExpiredRefundRestoreInventory()); } + /** 过期退款开关关闭时仍固化用餐日核销结束时间,后续重扫只能使用这份订单快照。 */ + @Test + void disabledExpiredRefundShouldStillPersistUsageEndSnapshot() { + HardOrderEntity order = new HardOrderEntity(); + MchAutoRefundConfigVo config = new MchAutoRefundConfigVo(); + config.setId(7L); + config.setEnabled("0"); + config.setExpiredReserveRefundEnabled("0"); + config.setExpiredReserveRefundDelayHours(1); + HardApptReservationService.ReservationValidation validation = new HardApptReservationService.ReservationValidation( + null, "2", LocalDate.of(2026, 8, 23), 9L, + LocalDateTime.of(2026, 8, 23, 22, 0), LocalDateTime.of(2026, 8, 23, 23, 59, 59)); + + ReflectionTestUtils.invokeMethod(service, "applyDailyMenuAutoRefundSnapshot", order, validation, config); + + assertEquals(LocalDateTime.of(2026, 8, 23, 23, 59, 59), order.getExpiredRefundUsageEndTime()); + assertNull(order.getExpiredRefundConfigId()); + assertNull(order.getExpiredRefundDeadline()); + } + /** * 构造旧消费机通用核销接口请求。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdMchFzRelationSaveDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdMchFzRelationSaveDto.java new file mode 100644 index 00000000..146584fd --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdMchFzRelationSaveDto.java @@ -0,0 +1,59 @@ +package com.bsd.pjcloud.thirdparty.dto.pay; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 商户分账关系管理参数。 + * + *

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

+ */ +@Data +public class ThirdMchFzRelationSaveDto { + /** 已有关系主键;新增时为空。 */ + private Long id; + + /** 关系所属商户主键,必须与支付配置所属商户一致。 */ + @NotNull(message = "商户不能为空") + private Long mchId; + + /** 关系使用的支付配置主键,决定渠道、二级商户和平台账号。 */ + @NotNull(message = "支付配置不能为空") + private Long paySettingId; + + /** 微信 sub_mchid 或支付宝直付通 smid/ip_role_id;编辑后变化时服务端创建新关系版本。 */ + @Size(max = 64, message = "二级商户标识不能超过64个字符") + private String channelSubMchId; + + /** 支付宝平台账号业务标识,仅用于关系审计;空字符串表示使用平台默认标识。 */ + @Size(max = 64, message = "平台账号键不能超过64个字符") + private String accountKey; + + /** 支付宝第三方代调用授权令牌;为空表示新增时不使用、编辑时保留原值。 */ + @Size(max = 512, message = "支付宝授权令牌不能超过512个字符") + private String appAuthToken; + + /** 渠道接收方类型;新增时必填,编辑后变化时必须同时提交完整新标识并创建新关系版本。 */ + private String receiverType; + + /** 渠道接收方账号、登录号、openId 或卡别名号;编辑时为空表示保留原完整值,非空变化时创建新关系版本。 */ + @Size(max = 128, message = "接收方标识不能超过128个字符") + private String receiverAccount; + + /** 渠道展示的接收方名称;支付宝和微信均允许为空,变化后原关系会使用新请求号重新绑定。 */ + @Size(max = 128, message = "接收方名称不能超过128个字符") + private String receiverName; + + /** 平台抽佣比例,单位为百分比,最多保留三位小数;新增关系时必填,修改默认关系时可省略。 */ + private Double fzRatio; + + /** 微信分账关系类型;支付宝关系不使用,变化后原关系会使用新请求号重新绑定。 */ + @Size(max = 64, message = "关系类型不能超过64个字符") + private String channelRelationType; + + /** 是否设为当前支付配置默认关系,1为是、0为否。 */ + private String isDefault; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java index 0584deec..3a0eedd7 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java @@ -40,14 +40,18 @@ public class ThirdMchFzRelationEntity extends Model { private String channelAppId; /** 微信 sub_mchid 或支付宝 smid/ip_role_id,用于绑定关系与商户支付配置对应。 */ private String channelSubMchId; - /** 支付宝命名平台账号键;默认支付宝账号及微信关系为空。 */ + /** 支付宝平台账号业务标识,仅用于关系审计;SDK 密钥统一读取服务端支付宝平台配置,微信关系为空。 */ private String accountKey; - /** 渠道接收方类型,微信为 MERCHANT_ID,支付宝为 userId 或 loginName。 */ + /** 支付宝第三方代调用授权令牌;仅服务端渠道调用使用,管理接口不得返回完整值。 */ + private String appAuthToken; + /** 渠道接收方类型,微信使用 SDK ReceiverType,支付宝支持 userId、loginName、openId、cardAliasNo。 */ private String receiverType; /** 平台分佣接收账号,发起渠道请求时使用;管理端展示必须脱敏。 */ private String receiverAccount; /** 平台分佣接收方名称快照,渠道不要求时允许为空。 */ private String receiverName; + /** 当前关系的平台抽佣比例,单位为百分比,范围为0到100,持久化精度为0.001%;新支付和分账金额只读取该关系快照。 */ + private Double fzRatio; /** 渠道关系类型,微信默认 SERVICE_PROVIDER,支付宝关系允许为空。 */ private String channelRelationType; /** 渠道绑定稳定幂等号;支付宝重试必须保持不变,微信用于本地审计。 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdMchFzRelationVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdMchFzRelationVo.java new file mode 100644 index 00000000..c07bdfe3 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdMchFzRelationVo.java @@ -0,0 +1,51 @@ +package com.bsd.pjcloud.thirdparty.vo.pay; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 管理端展示的商户分账关系。 + * + *

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

+ */ +@Data +public class ThirdMchFzRelationVo { + /** 关系主键。 */ + private Long id; + /** 关系所属商户主键。 */ + private Long mchId; + /** 关系使用的支付配置主键。 */ + private Long paySettingId; + /** 支付平台编码:10微信、20支付宝。 */ + private String platformType; + /** 微信子商户号或支付宝直付通 smid/ip_role_id。 */ + private String channelSubMchId; + /** 支付宝平台账号业务标识,仅用于关系审计;微信为空。 */ + private String accountKey; + /** 是否已配置支付宝授权令牌;管理端只接收布尔状态,绝不返回完整令牌。 */ + private Boolean appAuthTokenConfigured; + /** 接收方类型。 */ + private String receiverType; + /** 脱敏后的接收方标识。 */ + private String receiverAccount; + /** 接收方展示名称。 */ + private String receiverName; + /** 当前关系的平台抽佣比例,单位为百分比,最多保留三位小数(0.001%)。 */ + private Double fzRatio; + /** 微信关系类型;支付宝为空。 */ + private String channelRelationType; + /** 当前关系绑定状态。 */ + private String status; + /** 是否为当前支付配置默认关系。 */ + private String isDefault; + /** 渠道确认绑定时间。 */ + private LocalDateTime boundTime; + /** 最近一次渠道状态。 */ + private String lastExternalStatus; + /** 最近一次错误摘要。 */ + private String bindError; + /** 最近一次绑定或查询时间。 */ + private LocalDateTime lastQueryTime; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java index 218e8d88..5c01d96c 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/AppPlusPayConfig.java @@ -4,9 +4,6 @@ import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; -import java.util.LinkedHashMap; -import java.util.Map; - /** * 小程序平台配置 */ @@ -18,7 +15,7 @@ public class AppPlusPayConfig { private WechatConfig wechatConfig; - private AliPayConfig aliPayConfig; + private AliPayConfig AliPayConfig; @Data @@ -156,26 +153,6 @@ public class AppPlusPayConfig { */ private String subscribeUrl; - /** - * 可选的平台支付宝命名账号,键由商户支付配置中的 accountKey 引用;空配置仍使用当前账号本身。 - */ - private Map accounts = new LinkedHashMap<>(); - - /** - * 直付通平台抽佣接收支付宝账号,类型使用 userId 或 loginName。 - */ - private String royaltyTransIn; - - /** - * 直付通平台抽佣接收账号类型,支付宝支持 userId 或 loginName。 - */ - private String royaltyTransInType; - - /** - * 直付通平台抽佣接收方名称,用于支付宝分账请求描述。 - */ - private String royaltyTransInName; - } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdMchFzRelationController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdMchFzRelationController.java new file mode 100644 index 00000000..3718e57e --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdMchFzRelationController.java @@ -0,0 +1,103 @@ +package com.bsd.pjcloud.thirdparty.controller.pay; + +import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.log.annotation.SysLog; +import com.bsd.pjcloud.common.security.util.SecurityUtils; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdMchFzRelationSaveDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; +import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdMchFzRelationVo; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 商户支付分账关系管理接口。 + * + *

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

+ */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/third-mch-fz-relation") +@Tag(name = "商户支付分账关系", description = "商户微信/支付宝分账关系管理") +public class ThirdMchFzRelationController { + + /** 分账关系业务服务。 */ + private final ThirdMchFzRelationService relationService; + /** 商户服务,用于复核当前管理员可访问的商户范围。 */ + private final RemoteMchService remoteMchService; + + /** 查询支付配置下的全部关系。 */ + @Operation(summary = "查询分账关系") + @GetMapping("/list") + @PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')") + public R> list(@RequestParam Long mchId, @RequestParam Long paySettingId) { + requireMerchantAccess(mchId); + return R.ok(relationService.listForManage(mchId, paySettingId)); + } + + /** 新增关系或编辑关系展示名称、抽佣比例、授权 token 和默认标记。 */ + @Operation(summary = "保存分账关系") + @SysLog("保存商户支付分账关系") + @PostMapping + @PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')") + public R save(@Valid @RequestBody ThirdMchFzRelationSaveDto dto) { + requireMerchantAccess(dto.getMchId()); + return R.ok(relationService.saveForManage(dto)); + } + + /** 删除关系;已绑定关系由服务层保留为历史事实。 */ + @Operation(summary = "删除分账关系") + @SysLog("删除商户支付分账关系") + @DeleteMapping + @PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')") + public R remove(@RequestBody List ids) { + if (ids != null) { + for (Long id : ids) { + ThirdMchFzRelationEntity relation = relationService.getById(id); + if (relation == null) { + continue; + } + requireMerchantAccess(relation.getMchId()); + } + } + return R.ok(relationService.removeForManage(ids)); + } + + /** 使用原关系主键和原支付宝请求号重新排队绑定。 */ + @Operation(summary = "重试分账关系绑定") + @SysLog("重试商户支付分账关系绑定") + @PostMapping("/{id}/retry") + @PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')") + public R retry(@PathVariable Long id, @RequestParam Long mchId) { + requireMerchantAccess(mchId); + ThirdMchFzRelationEntity relation = relationService.getById(id); + if (relation == null || !mchId.equals(relation.getMchId())) { + throw new IllegalArgumentException("分账关系不属于指定商户"); + } + return R.ok(relationService.retryForManage(id)); + } + + /** 复核管理员商户数据权限,避免仅凭前端 mchId 越权查看其他商户关系。 */ + private void requireMerchantAccess(Long mchId) { + R> result = remoteMchService.getSelfMchIdList(SecurityUtils.getUser().getId(), MerchantTypeEnum.all()); + if (result == null || !result.isOk() || result.getData() == null || !result.getData().contains(mchId)) { + throw new IllegalArgumentException("当前用户无权管理该商户分账关系"); + } + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java index 49652111..b94029ad 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java @@ -1,29 +1,87 @@ package com.bsd.pjcloud.thirdparty.service; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdMchFzRelationSaveDto; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdMchFzRelationVo; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import java.util.List; + /** * 商户支付分账关系服务,负责把 {@code mch_manage} 的支付配置映射为渠道关系事实。 */ public interface ThirdMchFzRelationService { /** - * 根据商户当前启用支付配置物化默认平台分佣关系;已有相同身份关系只做幂等复用。 + * 查询商户支付配置下的全部关系,账号必须以脱敏视图返回。 + * + * @param mchId 商户主键 + * @param paySettingId 支付配置主键 + * @return 当前租户未删除关系,默认关系排在前面 + */ + List listForManage(Long mchId, Long paySettingId); + + /** + * 新增或更新关系管理记录;渠道身份变化时创建新版本,避免覆盖历史支付订单引用的关系事实。 + * + * @param dto 管理端关系参数 + * @return 保存后的脱敏关系;身份变化时返回新关系主键 + */ + ThirdMchFzRelationVo saveForManage(ThirdMchFzRelationSaveDto dto); + + /** + * 删除关系;已绑定关系只标记为历史关系,默认关系删除前必须自动切换到可用替代关系。 + * + * @param ids 关系主键列表 + * @return 全部处理成功时返回 true + */ + boolean removeForManage(List ids); + + /** + * 将关系放回待绑定状态并创建稳定任务,人工重试不得生成新的关系或请求号。 + * + * @param id 关系主键 + * @return 任务已重新排队时返回 true + */ + boolean retryForManage(Long id); + + /** + * 同步商户支付配置生命周期;渠道字段迁入关系表后,只停用已删除或禁用配置的默认关系。 * * @param merchant 商户及其启用支付配置,来源于 wallet 服务的内部快照 */ void syncDefaultRelations(MchManageVo merchant); /** - * 获取支付创建时必须使用的已绑定关系;有抽佣配置但关系未绑定时直接拒绝支付。 + * 获取支付创建时必须使用的默认且已绑定关系;关系缺失或未绑定时直接拒绝支付。 * * @param setting 商户支付配置 - * @return 已绑定关系;未启用平台抽佣时返回 {@code null} + * @return 当前默认且已绑定的关系事实 */ ThirdMchFzRelationEntity requireBound(MchPaySettingVo setting); + /** + * 校验内部支付请求携带的关系仍属于当前商户支付配置,防止绕过编排层伪造关系主键。 + * + * @param relationId 支付编排层解析出的关系主键 + * @param setting 当前商户支付配置 + * @return 默认、已绑定且抽佣比例有效的关系快照 + */ + ThirdMchFzRelationEntity requirePaymentRelation(Long relationId, MchPaySettingVo setting); + + /** + * 使用固定关系生成订单级渠道配置快照,供渠道调用及后续查单、退款恢复使用。 + * + *

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

+ * + * @param setting 当前启用的支付配置,只提供商户、平台和配置主键 + * @param relation 已校验的默认关系事实 + * @return 包含关系渠道参数的独立支付配置快照 + */ + MchPaySettingVo createPaymentSettingSnapshot(MchPaySettingVo setting, ThirdMchFzRelationEntity relation); + /** * 按支付订单保存的关系主键读取历史关系快照,供分账和退分账使用。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java index 4d5cb3b1..26614ace 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImpl.java @@ -22,6 +22,7 @@ import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService; import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateAlipayOauthIdentity; import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch; import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext; @@ -33,6 +34,7 @@ import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.validation.annotation.Validated; import java.math.BigDecimal; @@ -89,6 +91,10 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ */ private final ThirdAppPlusAliPayService thirdAppPlusAliPayService; + /** 聚合子订单创建前校验支付宝直付通关系归属、绑定状态和抽佣比例。 */ + @Autowired(required = false) + private ThirdMchFzRelationService mchFzRelationService; + /** * 当前应用配置对应的支付宝客户端,延迟初始化并在请求间复用。 */ @@ -281,7 +287,7 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ payOrder.setMchId(context.getMchId()); payOrder.setPrice(context.getAmount()); payOrder.setFzRelationId(context.getFzRelationId()); - payOrder.setEnableFz(paySetting.getFzRatio() != null && paySetting.getFzRatio() > 0 + payOrder.setEnableFz(context.getFzRelationId() != null ? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode()); payOrder.setPayNo(context.getPayNo()); payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType()); @@ -326,18 +332,13 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ /** 聚合支付底层再次校验分佣关系,防止绕过编排层直接创建无关系直付通订单。 */ private void validateFzRelation(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting) { - if (paySetting.getFzRatio() == null || paySetting.getFzRatio() <= 0) { - if (paySetting.getFzRatio() != null && (!Double.isFinite(paySetting.getFzRatio()) || paySetting.getFzRatio() < 0)) { - throw new RuntimeException("支付宝平台抽佣比例不能为负数"); - } + if (context == null || context.getFzRelationId() == null) { return; } - if (!Double.isFinite(paySetting.getFzRatio()) || paySetting.getFzRatio() > 100) { - throw new RuntimeException("支付宝平台抽佣比例不能超过100%"); - } - if (context == null || context.getFzRelationId() == null) { - throw new RuntimeException("支付宝直付通平台分佣关系尚未绑定"); + if (mchFzRelationService == null) { + throw new RuntimeException("支付宝平台分佣关系服务不可用"); } + mchFzRelationService.requirePaymentRelation(context.getFzRelationId(), paySetting); } /** diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java index dc955f01..34367cee 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java @@ -31,7 +31,6 @@ import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext; import com.bsd.pjcloud.thirdparty.service.*; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo; -import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; @@ -574,11 +573,11 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay ThirdAggregateCashierLaunch launch; if (ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.ALI_PAY)) { MchPaySettingVo setting = getRequiredAliPaySetting(merchant); - context.setFzRelationId(resolveFzRelation(setting)); + setting = applyDefaultPaymentRelation(setting, context); launch = aggregateAliPayService.createPay(context, setting, alipayIdentity); } else { MchPaySettingVo setting = getRequiredWechatPaySetting(merchant); - context.setFzRelationId(resolveFzRelation(setting)); + setting = applyDefaultPaymentRelation(setting, context); launch = aggregateWechatPayService.createPay(context, setting, payerIdentity); } if (!aggregatePaySessionService.markPaying(session.getId(), payNo)) { @@ -1051,7 +1050,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay } /** - * 选择已启用且包含直付通二级商户标识的支付宝商户配置;app_auth_token 可为空。 + * 选择已启用的支付宝支付配置;直付通渠道参数在选定平台后从默认关系解析。 * * @param merchant 收款商户详情 * @return 当前商户支付宝配置 @@ -1061,18 +1060,14 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay .filter(item -> ObjectUtil.equals(item.getPlatformType(), AppPlusPlatformPayType.ALI_PAY.getType())) .filter(item -> ObjectUtil.equals(item.getEnable(), YesNoEnum.YES.getCode())) .findFirst().orElse(null); - if (setting == null || StrUtil.isBlank(setting.getSettingJson())) { + if (setting == null) { throw new RuntimeException("商户支付宝支付配置未启用"); } - MchPaySettingEntity.AliPaySetting detail = JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - if (detail == null || StrUtil.isBlank(detail.getSubMchid())) { - throw new RuntimeException("商户支付宝直付通二级商户标识未配置"); - } return setting; } /** - * 选择已启用且包含 sub_mchid 的微信商户配置。 + * 选择已启用的微信支付配置;子商户号在选定平台后从默认关系解析。 * * @param merchant 收款商户详情 * @return 当前商户微信支付配置 @@ -1082,13 +1077,9 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay .filter(item -> ObjectUtil.equals(item.getPlatformType(), AppPlusPlatformPayType.WECHAT.getType())) .filter(item -> ObjectUtil.equals(item.getEnable(), YesNoEnum.YES.getCode())) .findFirst().orElse(null); - if (setting == null || StrUtil.isBlank(setting.getSettingJson())) { + if (setting == null) { throw new RuntimeException("商户微信支付配置未启用"); } - MchPaySettingEntity.WechatPaySetting detail = JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class); - if (detail == null || StrUtil.isBlank(detail.getSubMchid())) { - throw new RuntimeException("商户微信子商户号未配置"); - } return setting; } @@ -1214,14 +1205,16 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay } /** - * 聚合支付在选定平台后解析默认平台分佣关系;多平台会话不会合并不同商户关系。 + * 聚合支付选定平台后解析默认关系,并生成只属于当前子订单的渠道配置快照。 * * @param setting 已确认启用的支付配置 - * @return 绑定关系主键;抽佣比例未启用时为空 + * @param context 聚合子订单创建上下文 + * @return 由关系事实生成的支付配置快照 */ - private Long resolveFzRelation(MchPaySettingVo setting) { + private MchPaySettingVo applyDefaultPaymentRelation(MchPaySettingVo setting, ThirdAggregatePayCreateContext context) { ThirdMchFzRelationEntity relation = mchFzRelationService.requireBound(setting); - return relation == null ? null : relation.getId(); + context.setFzRelationId(relation.getId()); + return mchFzRelationService.createPaymentSettingSnapshot(setting, relation); } /** diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImpl.java index f116dae8..3ea4cde1 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateWechatPayServiceImpl.java @@ -9,6 +9,7 @@ import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService; import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch; import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext; import com.bsd.pjcloud.thirdparty.service.ThirdAggregateWechatPayService; @@ -28,6 +29,7 @@ import com.wechat.pay.java.service.partnerpayments.jsapi.model.Transaction; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.validation.annotation.Validated; import java.time.LocalDateTime; @@ -90,6 +92,10 @@ public class ThirdAggregateWechatPayServiceImpl implements ThirdAggregateWechatP */ private final ThirdPayOrderService thirdPayOrderService; + /** 聚合子订单创建前校验分账关系仍属于当前商户支付配置。 */ + @Autowired(required = false) + private ThirdMchFzRelationService mchFzRelationService; + /** * 调用服务商统一公众号 OAuth 接口换取 sp_openid。 * @@ -163,9 +169,12 @@ public class ThirdAggregateWechatPayServiceImpl implements ThirdAggregateWechatP request.setAmount(amount); request.setPayer(payer); SettleInfo settleInfo = new SettleInfo(); - boolean enableFz = paySetting.getFzRatio() != null && paySetting.getFzRatio() > 0; - if (enableFz && context.getFzRelationId() == null) { - throw new RuntimeException("微信平台分佣关系尚未绑定"); + boolean enableFz = context.getFzRelationId() != null; + if (enableFz) { + if (mchFzRelationService == null) { + throw new RuntimeException("微信平台分佣关系服务不可用"); + } + mchFzRelationService.requirePaymentRelation(context.getFzRelationId(), paySetting); } settleInfo.setProfitSharing(enableFz); request.setSettleInfo(settleInfo); @@ -295,7 +304,7 @@ public class ThirdAggregateWechatPayServiceImpl implements ThirdAggregateWechatP payOrder.setMchId(context.getMchId()); payOrder.setPrice(context.getAmount()); payOrder.setFzRelationId(context.getFzRelationId()); - payOrder.setEnableFz(paySetting.getFzRatio() != null && paySetting.getFzRatio() > 0 + payOrder.setEnableFz(context.getFzRelationId() != null ? com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode() : com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.NO.getCode()); payOrder.setPayNo(context.getPayNo()); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java index d6941bbb..d336663e 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java @@ -48,6 +48,7 @@ import com.alipay.api.response.AlipayTradeOrderSettleResponse; import com.alipay.api.response.AlipayTradeOrderSettleQueryResponse; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationStatusEnum; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum; @@ -78,6 +79,7 @@ import org.springframework.validation.annotation.Validated; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.function.BiConsumer; @@ -155,15 +157,21 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService @Autowired(required = false) private ThirdAggregatePaySessionService aggregatePaySessionService; - /** 实时写入支付查单和退款查单任务,不负责周期调度。 */ + /** + * 实时写入支付查单和退款查单任务,不负责周期调度。 + */ @Autowired(required = false) private ThirdPaymentTaskEnqueuer paymentTaskEnqueuer; - /** 支付订单创建时固定的平台分佣关系快照。 */ + /** + * 支付订单创建时固定的平台分佣关系快照。 + */ @Autowired(required = false) private ThirdMchFzRelationService mchFzRelationService; - /** 支付订单与首任务预留事务;可选注入仅用于兼容既有纯单元测试构造方式。 */ + /** + * 支付订单与首任务预留事务;可选注入仅用于兼容既有纯单元测试构造方式。 + */ @Autowired(required = false) private ThirdPaymentOrderReservationService paymentOrderReservationService; @@ -204,58 +212,11 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService return alipayClient; } - /** - * 按商户支付快照选择平台支付宝账号。accountKey 只作为平台配置键, - * 不接受客户端传入的应用密钥或账号内容。 - * - * @param aliPaySetting 商户支付宝配置快照 - * @return 对应支付宝客户端 - */ - private AlipayClient getAlipayClient(MchPaySettingEntity.AliPaySetting aliPaySetting) { - AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(aliPaySetting); - if (config == appPlusPayConfig.getAliPayConfig()) { - return getAlipayClient(); - } - try { - AlipayConfig alipayConfig = new AlipayConfig(); - alipayConfig.setServerUrl("https://openapi.alipay.com/gateway.do"); - alipayConfig.setAppId(config.getAppId()); - alipayConfig.setPrivateKey(config.getAppPrivateKey()); - alipayConfig.setFormat("json"); - alipayConfig.setAlipayPublicKey(config.getAlipayPublicKey()); - alipayConfig.setCharset("UTF-8"); - alipayConfig.setSignType("RSA2"); - return new DefaultAlipayClient(alipayConfig); - } catch (Exception exception) { - log.error("初始化命名支付宝客户端失败,accountKey:{}", aliPaySetting == null ? null : aliPaySetting.getAccountKey(), exception); - throw new RuntimeException("支付宝客户端初始化失败"); - } - } - - /** - * 解析商户配置引用的平台支付宝账号。 - * - * @param aliPaySetting 商户支付宝配置 - * @return 默认或命名支付宝账号配置 - */ - private AppPlusPayConfig.AliPayConfig resolveAliPayConfig(MchPaySettingEntity.AliPaySetting aliPaySetting) { - AppPlusPayConfig.AliPayConfig defaultConfig = appPlusPayConfig.getAliPayConfig(); - if (defaultConfig == null || StrUtil.isBlank(aliPaySetting == null ? null : aliPaySetting.getAccountKey())) { - return defaultConfig; - } - AppPlusPayConfig.AliPayConfig account = defaultConfig.getAccounts() == null - ? null : defaultConfig.getAccounts().get(aliPaySetting.getAccountKey()); - if (account == null) { - throw new RuntimeException("支付宝平台账号不存在"); - } - return account; - } - /** * 仅在配置了代调用授权令牌时加入公共参数,避免把空 token 发送给支付宝。 * * @param textParamWriter 支付宝请求的附加文本参数写入方法 - * @param aliPaySetting 商户支付宝配置 + * @param aliPaySetting 商户支付宝配置 */ private void putAppAuthToken(BiConsumer textParamWriter, MchPaySettingEntity.AliPaySetting aliPaySetting) { @@ -279,11 +240,11 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } /** - * 按整数分计算平台抽佣金额,并拒绝无法形成至少一分钱有效分账的配置。 + * 按整数分计算平台抽佣金额;向下取整不足一分钱时按一分钱提交。 * * @param orderAmount 支付订单金额,单位分 - * @param fzRatio 平台抽佣百分比 - * @return 向下取整后的平台抽佣金额,单位分 + * @param fzRatio 平台抽佣百分比 + * @return 向下取整并应用一分钱下限后的平台抽佣金额,单位分 */ private int calculateFzAmount(Integer orderAmount, Double fzRatio) { if (orderAmount == null || orderAmount <= 0 || fzRatio == null || fzRatio <= 0 || fzRatio > 100) { @@ -291,25 +252,23 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } int fzAmount = BigDecimal.valueOf(orderAmount).multiply(BigDecimal.valueOf(fzRatio)) .divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN).intValueExact(); - if (fzAmount < 1 || fzAmount > orderAmount) { + // 支付宝直付通要求单笔平台抽佣至少1分,低比例向下取整为0时按1分提交。 + fzAmount = Math.max(1, fzAmount); + if (fzAmount > orderAmount) { throw new RuntimeException("支付宝直付通平台抽佣金额必须不少于1分且不能超过订单金额"); } return fzAmount; } /** - * 解析支付回调地址。命名账号可以单独配置地址,未配置时复用平台默认回调地址。 + * 解析平台统一支付宝支付回调地址。 * - * @param aliPaySetting 商户支付宝配置快照 * @return 完整支付回调地址 */ - private String resolvePayNotifyUrl(MchPaySettingEntity.AliPaySetting aliPaySetting) { - AppPlusPayConfig.AliPayConfig selected = resolveAliPayConfig(aliPaySetting); - AppPlusPayConfig.AliPayConfig defaults = appPlusPayConfig.getAliPayConfig(); - String baseUrl = StrUtil.firstNonBlank(selected == null ? null : selected.getBaseCallbackUrl(), - defaults == null ? null : defaults.getBaseCallbackUrl()); - String callbackUrl = StrUtil.firstNonBlank(selected == null ? null : selected.getPayCallbackUrl(), - defaults == null ? null : defaults.getPayCallbackUrl()); + private String resolvePayNotifyUrl() { + AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.getAliPayConfig(); + String baseUrl = config == null ? null : config.getBaseCallbackUrl(); + String callbackUrl = config == null ? null : config.getPayCallbackUrl(); if (StrUtil.hasBlank(baseUrl, callbackUrl)) { throw new RuntimeException("支付宝支付回调地址未配置"); } @@ -317,18 +276,14 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } /** - * 解析退款回调地址。命名账号可以单独配置地址,未配置时复用平台默认回调地址。 + * 解析平台统一支付宝退款回调地址。 * - * @param aliPaySetting 商户支付宝配置快照 * @return 完整退款回调地址 */ - private String resolveRefundNotifyUrl(MchPaySettingEntity.AliPaySetting aliPaySetting) { - AppPlusPayConfig.AliPayConfig selected = resolveAliPayConfig(aliPaySetting); - AppPlusPayConfig.AliPayConfig defaults = appPlusPayConfig.getAliPayConfig(); - String baseUrl = StrUtil.firstNonBlank(selected == null ? null : selected.getBaseCallbackUrl(), - defaults == null ? null : defaults.getBaseCallbackUrl()); - String callbackUrl = StrUtil.firstNonBlank(selected == null ? null : selected.getRefundCallbackUrl(), - defaults == null ? null : defaults.getRefundCallbackUrl()); + private String resolveRefundNotifyUrl() { + AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.getAliPayConfig(); + String baseUrl = config == null ? null : config.getBaseCallbackUrl(); + String callbackUrl = config == null ? null : config.getRefundCallbackUrl(); if (StrUtil.hasBlank(baseUrl, callbackUrl)) { throw new RuntimeException("支付宝退款回调地址未配置"); } @@ -348,7 +303,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService /** * 按商户订单号查询支付宝交易状态。 * - * @param payNo 第三方支付服务生成的商户订单号 + * @param payNo 第三方支付服务生成的商户订单号 * @param settingJson 创建订单时保存的商户配置快照 * @return 支付宝查询响应;解析、网络或 SDK 异常时返回 {@code null} */ @@ -361,7 +316,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService model.setOutTradeNo(payNo); request.setBizModel(model); putAppAuthToken(request::putOtherTextParam, aliPaySetting); - return getAlipayClient(aliPaySetting).execute(request); + return getAlipayClient().execute(request); } catch (Exception e) { log.error("|{}|查询支付宝订单失败,payNo:{},exceptionType:{}", TAG, payNo, e.getClass().getSimpleName()); } @@ -371,8 +326,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService /** * 调用支付宝当面付预创建接口并保存订单及不含授权令牌的请求、响应快照。 * - * @param dto 已生成支付订单号的内部支付参数 - * @param setting 当前商户启用的支付宝支付配置 + * @param dto 已生成支付订单号的内部支付参数 + * @param setting 当前商户启用的支付宝支付配置 * @param expireTime 服务端二维码失效时间 * @return 支付订单号、二维码内容串和服务端过期时间 */ @@ -381,8 +336,13 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySetting(setting); ThirdPayOrderEntity payOrder = buildOrderCodePayOrder(dto, setting, expireTime); + if (dto.getFzRelationId() != null) { + ThirdMchFzRelationEntity relation = mchFzRelationService == null + ? null : mchFzRelationService.getById(dto.getFzRelationId()); + requirePaymentCreateRelation(relation, payOrder, aliPaySetting, setting); + } AlipayTradePrecreateRequest request = new AlipayTradePrecreateRequest(); - request.setNotifyUrl(resolvePayNotifyUrl(aliPaySetting)); + request.setNotifyUrl(resolvePayNotifyUrl()); AlipayTradePrecreateModel model = new AlipayTradePrecreateModel(); model.setOutTradeNo(dto.getOutTradeNo()); @@ -400,7 +360,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService AlipayTradePrecreateResponse response; try { - response = getAlipayClient(aliPaySetting).execute(request); + response = getAlipayClient().execute(request); } catch (Exception exception) { payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_FAILED.getType()); payOrder.setPayRes(buildExceptionSnapshot(exception).toString()); @@ -433,7 +393,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService /** * 调用支付宝撤销接口;异常时保留未支付状态,由上层定时任务继续重试。 * - * @param payNo 第三方支付服务生成的商户订单号 + * @param payNo 第三方支付服务生成的商户订单号 * @param settingJson 创建订单时保存的商户支付配置快照 * @return 支付宝撤销响应,调用异常时返回 {@code null} */ @@ -446,7 +406,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService model.setOutTradeNo(payNo); request.setBizModel(model); putAppAuthToken(request::putOtherTextParam, aliPaySetting); - return getAlipayClient(aliPaySetting).execute(request); + return getAlipayClient().execute(request); } catch (Exception exception) { log.error("|{}|撤销支付宝订单失败,payNo:{},exceptionType:{}", TAG, payNo, exception.getClass().getSimpleName()); return null; @@ -482,20 +442,27 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService if (dto.getAmount() == null || dto.getAmount() <= 10) { throw new RuntimeException("支付宝直付通支付金额必须大于10分"); } - boolean enableFz = setting.getFzRatio() != null && setting.getFzRatio() > 0; + boolean enableFz = dto.getFzRelationId() != null; if (enableFz) { - calculateFzAmount(dto.getAmount(), setting.getFzRatio()); + // 上层通常已经通过 requireBound 解析关系;底层再次核对是为了防止内部调用者伪造 + // fzRelationId,把其他商户、其他 smid 或尚未绑定的关系带入支付宝支付请求。 + ThirdMchFzRelationEntity relation = mchFzRelationService == null + ? null : mchFzRelationService.getById(dto.getFzRelationId()); + requirePaymentCreateRelation(relation, payOrder, aliPaySetting, setting); + calculateFzAmount(dto.getAmount(), relation.getFzRatio()); } payOrder.setEnableFz(enableFz ? com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode() : com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.NO.getCode()); AlipayTradeAppPayRequest request = new AlipayTradeAppPayRequest(); - request.setNotifyUrl(resolvePayNotifyUrl(aliPaySetting)); + request.setNotifyUrl(resolvePayNotifyUrl()); AlipayTradeAppPayModel model = new AlipayTradeAppPayModel(); + String totalAmount = fenToYuan(BigDecimal.valueOf(dto.getAmount())); + model.setOutTradeNo(dto.getOutTradeNo()); - model.setTotalAmount(fenToYuan(BigDecimal.valueOf(dto.getAmount()))); + model.setTotalAmount(totalAmount); model.setSubject("资安信"); // 超时时间15分钟 model.setTimeExpire(LocalDateTimeUtil.format(now.plusMinutes(15), DatePattern.NORM_DATETIME_PATTERN)); @@ -504,18 +471,23 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService model.setSubMerchant(subMerchant); SettleInfo directPaySettleInfo = new SettleInfo(); directPaySettleInfo.setSettlePeriodTime("1d"); + // 支付通必须填写`SettleDetailInfo` + ArrayList settleDetailInfoList = new ArrayList<>(); + SettleDetailInfo defaultSettleDetailInfo = new SettleDetailInfo(); + defaultSettleDetailInfo.setTransInType("defaultSettle"); + defaultSettleDetailInfo.setAmount(totalAmount); + settleDetailInfoList.add(defaultSettleDetailInfo); + directPaySettleInfo.setSettleDetailInfos(settleDetailInfoList); model.setSettleInfo(directPaySettleInfo); payOrder.setPayRep(JSONUtil.toJsonStr(model)); request.setBizModel(model); - putAppAuthToken(request::putOtherTextParam, aliPaySetting); if (paymentOrderReservationService != null) { paymentOrderReservationService.reserveAppPay(payOrder); } try { - AlipayTradeAppPayResponse response = getAlipayClient(aliPaySetting).sdkExecute(request); + AlipayTradeAppPayResponse response = getAlipayClient().sdkExecute(request); String orderStr = response.getBody(); - payOrder.setPayRes(orderStr); return orderStr; } catch (Exception e) { @@ -562,7 +534,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService updateObj.setIsBizCallback(com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.NO.getCode()); AlipayTradeRefundRequest request = new AlipayTradeRefundRequest(); - request.setNotifyUrl(resolveRefundNotifyUrl(aliPaySetting)); + request.setNotifyUrl(resolveRefundNotifyUrl()); AlipayTradeRefundModel model = new AlipayTradeRefundModel(); model.setOutTradeNo(thirdPayOrder.getPayNo()); @@ -626,7 +598,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } } try { - AlipayTradeRefundResponse response = getAlipayClient(aliPaySetting).execute(request); + AlipayTradeRefundResponse response = getAlipayClient().execute(request); updateObj.setRefundRes(response == null ? null : response.getBody()); // 退款接口返回 10000 只表示请求已受理,最终结果必须通过退款查询或回调确认。 if (response != null && !response.isSuccess() && !isRetryableAlipayError(response.getSubCode())) { @@ -669,7 +641,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService AlipayTradeFastpayRefundQueryRequest request = new AlipayTradeFastpayRefundQueryRequest(); request.setBizModel(model); putAppAuthToken(request::putOtherTextParam, aliPaySetting); - AlipayTradeFastpayRefundQueryResponse response = getAlipayClient(aliPaySetting).execute(request); + AlipayTradeFastpayRefundQueryResponse response = getAlipayClient().execute(request); if (isAlipayRefundQueryMissing(response)) { AlipayTradeRefundResponse retryResponse = retryAlipayRefund(payOrder, aliPaySetting); // 重提成功只表示支付宝受理;网络异常或系统错误的结果仍未知,下一轮必须继续查询。 @@ -706,7 +678,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService *

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

* - * @param payOrder 退款中的支付订单 + * @param payOrder 退款中的支付订单 * @param aliPaySetting 支付创建时的支付宝商户配置 * @return 支付宝重提响应;无法安全重提或调用结果未知时为空 */ @@ -722,9 +694,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } AlipayTradeRefundRequest retryRequest = new AlipayTradeRefundRequest(); retryRequest.setBizModel(retryModel); - retryRequest.setNotifyUrl(resolveRefundNotifyUrl(aliPaySetting)); + retryRequest.setNotifyUrl(resolveRefundNotifyUrl()); putAppAuthToken(retryRequest::putOtherTextParam, aliPaySetting); - AlipayTradeRefundResponse retryResponse = getAlipayClient(aliPaySetting).execute(retryRequest); + AlipayTradeRefundResponse retryResponse = getAlipayClient().execute(retryRequest); ThirdPayOrderEntity update = new ThirdPayOrderEntity(); update.setRefundRes(retryResponse == null ? null : retryResponse.getBody()); thirdPayOrderService.update(update, new LambdaUpdateWrapper() @@ -742,9 +714,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService /** * 保存支付宝退款中退分账部分的最终响应和状态。 * - * @param fzOrder 原分账订单;未发生退分账时允许为空 + * @param fzOrder 原分账订单;未发生退分账时允许为空 * @param response 支付宝退款响应 - * @param status 分账退款终态 + * @param status 分账退款终态 */ private void updateAlipayFzRefundResult(ThirdFzOrderEntity fzOrder, AlipayTradeRefundResponse response, ThirdFzOrderStatusEnum status) { @@ -783,6 +755,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService /** * 预留支付宝直付通分账事实;外部请求必须由后续独立任务执行。 + * * @param payOrder 已确认支付成功的支付宝订单 * @return 已存在或新建的分账订单;订单尚未满足分账条件时为空 */ @@ -809,7 +782,10 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService fzOrder.setTenantId(payOrder.getTenantId()); fzOrder.setPayNo(payOrder.getPayNo()); fzOrder.setFzNo("fz_" + payOrder.getPayNo()); - fzOrder.setPrice(calculateFzAmount(payOrder.getPrice(), paySetting.getFzRatio())); + ThirdMchFzRelationEntity relation = mchFzRelationService == null ? null + : mchFzRelationService.getById(payOrder.getFzRelationId()); + requireDirectPayRelation(relation, payOrder, requireAliPaySetting(paySetting)); + fzOrder.setPrice(calculateFzAmount(payOrder.getPrice(), relation.getFzRatio())); fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_UNPAID.getType()); fzOrder.setFzTime(LocalDateTime.now()); fzOrder.setCreateRes(JSONUtil.createObj().set("provider", "ALIPAY").toString()); @@ -826,7 +802,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService * 补偿支付宝直付通结算、平台抽佣及已分账退款。支付宝此链路没有可替代本地幂等号的轮询结果, * 因此对超时或系统异常重复提交相同请求,显式业务失败才进入失败状态。 * - * @param fzOrder 已持久化的分账订单 + * @param fzOrder 已持久化的分账订单 * @param payOrder 对应的支付订单 */ @Override @@ -856,7 +832,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService /** * 执行并保存确认结算单阶段;相同阶段终态重复调用不会再次请求支付宝。 - * @param fzOrder 分账订单 + * + * @param fzOrder 分账订单 * @param payOrder 支付订单 * @return SUCCESS、PROCESSING 或 FAILED */ @@ -891,7 +868,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService /** * 提交平台抽佣单阶段;提交成功只记录 SUBMITTED,最终成功由查询阶段裁定。 - * @param fzOrder 分账订单 + * + * @param fzOrder 分账订单 * @param payOrder 支付订单 * @return SUBMITTED、PROCESSING 或 FAILED */ @@ -933,7 +911,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService /** * 查询平台抽佣最终结果;提交响应成功本身不能进入最终分账成功。 - * @param fzOrder 分账订单 + * + * @param fzOrder 分账订单 * @param payOrder 支付订单 * @return SUCCESS、PROCESSING 或 FAILED */ @@ -992,7 +971,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService return status; } - /** 读取并补齐支付宝分账快照根节点。 */ + /** + * 读取并补齐支付宝分账快照根节点。 + */ private JSONObject readDirectPaySnapshot(ThirdFzOrderEntity fzOrder) { JSONObject snapshot = StrUtil.isBlank(fzOrder.getCreateRes()) ? JSONUtil.createObj() : JSONUtil.parseObj(fzOrder.getCreateRes()); @@ -1000,7 +981,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService return snapshot; } - /** 读取支付订单创建时保存的商户设置快照。 */ + /** + * 读取支付订单创建时保存的商户设置快照。 + */ private MchPaySettingEntity requireMchPaySetting(ThirdPayOrderEntity payOrder) { MchPaySettingEntity setting = payOrder == null || StrUtil.isBlank(payOrder.getMchPaySettingJson()) ? null : JSONUtil.toBean(payOrder.getMchPaySettingJson(), MchPaySettingEntity.class); @@ -1010,12 +993,16 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService return setting; } - /** 从支付订单快照读取并校验直付通二级商户设置。 */ + /** + * 从支付订单快照读取并校验直付通二级商户设置。 + */ private MchPaySettingEntity.AliPaySetting requireAliPaySetting(ThirdPayOrderEntity payOrder) { return requireAliPaySetting(requireMchPaySetting(payOrder)); } - /** 从商户设置中读取并校验支付宝直付通配置。 */ + /** + * 从商户设置中读取并校验支付宝直付通配置。 + */ private MchPaySettingEntity.AliPaySetting requireAliPaySetting(MchPaySettingEntity setting) { MchPaySettingEntity.AliPaySetting aliPaySetting = StrUtil.isBlank(setting.getSettingJson()) ? null : JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); @@ -1025,7 +1012,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService return aliPaySetting; } - /** 将分账订单终态转换为支付宝阶段状态。 */ + /** + * 将分账订单终态转换为支付宝阶段状态。 + */ private String resolvePersistedFzStatus(ThirdFzOrderEntity fzOrder) { if (fzOrder == null) { return "FAILED"; @@ -1039,7 +1028,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService return "PROCESSING"; } - /** 创建不含账号、密钥和授权令牌的确定性失败阶段快照。 */ + /** + * 创建不含账号、密钥和授权令牌的确定性失败阶段快照。 + */ private JSONObject failedDirectPayStage(String error) { return JSONUtil.createObj().set("status", "FAILED") .set("error", StrUtil.blankToDefault(error, "支付宝直付通配置无效")) @@ -1049,9 +1040,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService /** * 原子保存直付通阶段快照及可选终态,禁止较晚返回的旧请求覆盖并发实例已写入的成功或退款状态。 * - * @param fzOrder 当前轮询读取的待处理分账订单 - * @param snapshot 合并确认结算和平台抽佣后的结构化快照 - * @param stage 本轮刚完成的阶段,用于提取请求和错误摘要 + * @param fzOrder 当前轮询读取的待处理分账订单 + * @param snapshot 合并确认结算和平台抽佣后的结构化快照 + * @param stage 本轮刚完成的阶段,用于提取请求和错误摘要 * @param terminalStatus 本轮明确得到的最终状态;处理中时为空 * @return 仅当数据库记录仍处于待处理状态且本次更新成功时返回 {@code true} */ @@ -1100,7 +1091,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService /** * 查询支付宝退款并同步退分账终态;退款查询至少由调度任务在发起十秒后执行。 * - * @param fzOrder 退分账中的分账订单 + * @param fzOrder 退分账中的分账订单 * @param payOrder 对应退款中的支付订单 */ private void pollAlipayFzRefund(ThirdFzOrderEntity fzOrder, ThirdPayOrderEntity payOrder) { @@ -1140,7 +1131,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService /** * 调用支付宝确认结算接口并保存稳定请求号。 * - * @param payOrder 支付订单 + * @param payOrder 支付订单 * @param aliPaySetting 商户支付宝配置 * @return 确认结算阶段快照 */ @@ -1169,7 +1160,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService stage.set("outRequestNo", outRequestNo); stage.set("request", JSONUtil.parseObj(JSONUtil.toJsonStr(model))); try { - AlipayTradeSettleConfirmResponse response = getAlipayClient(aliPaySetting).execute(request); + AlipayTradeSettleConfirmResponse response = getAlipayClient().execute(request); stage.set("response", response == null ? null : JSONUtil.parseObj(JSONUtil.toJsonStr(response))); stage.set("externalStatus", response == null ? "EMPTY_RESPONSE" : response.getSubCode()); stage.set("status", resolveSettleConfirmStatus(response)); @@ -1201,14 +1192,14 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService /** * 调用支付宝平台抽佣分账接口。 * - * @param payOrder 支付订单 + * @param payOrder 支付订单 * @param mchPaySetting 商户支付设置 * @param aliPaySetting 商户支付宝配置 - * @param fzOrder 分账订单 + * @param fzOrder 分账订单 * @return 平台抽佣阶段快照 */ private JSONObject settleDirectPayRoyalty(ThirdPayOrderEntity payOrder, MchPaySettingEntity mchPaySetting, - MchPaySettingEntity.AliPaySetting aliPaySetting, ThirdFzOrderEntity fzOrder) { + MchPaySettingEntity.AliPaySetting aliPaySetting, ThirdFzOrderEntity fzOrder) { JSONObject stage = JSONUtil.createObj(); String outRequestNo = "royalty_" + payOrder.getPayNo(); ThirdMchFzRelationEntity relation = mchFzRelationService == null ? null : mchFzRelationService.getById(payOrder.getFzRelationId()); @@ -1251,7 +1242,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService stage.set("outRequestNo", outRequestNo); stage.set("request", JSONUtil.parseObj(JSONUtil.toJsonStr(model))); try { - AlipayTradeOrderSettleResponse response = getAlipayClient(aliPaySetting).execute(request); + AlipayTradeOrderSettleResponse response = getAlipayClient().execute(request); stage.set("response", response == null ? null : JSONUtil.parseObj(JSONUtil.toJsonStr(response))); stage.set("externalStatus", response == null ? "EMPTY_RESPONSE" : response.getSubCode()); stage.set("status", resolveRoyaltySubmissionStatus(response)); @@ -1267,27 +1258,25 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } /** - * 校验平台抽佣接收方账户类型,直付通分账只允许支付宝 userId 或 loginName。 - * - * @param accountType 支付宝账户类型 + * 校验平台抽佣接收方账户类型,编码直接传给支付宝分账和退分账模型。 */ private void validateRoyaltyAccountType(String accountType) { - if (!"userId".equals(accountType) && !"loginName".equals(accountType)) { - throw new RuntimeException("平台支付宝抽佣接收方类型只允许userId或loginName"); + if (!List.of("userId", "loginName", "openId", "cardAliasNo").contains(accountType)) { + throw new RuntimeException("平台支付宝抽佣接收方类型只允许userId、loginName、openId或cardAliasNo"); } } /** * 校验支付宝支付订单固定的直付通关系;历史关系允许继续退分账,但禁止回退当前可变配置。 * - * @param relation 支付创建时保存的关系快照 - * @param payOrder 关系所属支付订单 + * @param relation 支付创建时保存的关系快照 + * @param payOrder 关系所属支付订单 * @param aliPaySetting 支付订单创建时保存的支付宝配置快照 */ private void requireDirectPayRelation(ThirdMchFzRelationEntity relation, - ThirdPayOrderEntity payOrder, - MchPaySettingEntity.AliPaySetting aliPaySetting) { - AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(aliPaySetting); + ThirdPayOrderEntity payOrder, + MchPaySettingEntity.AliPaySetting aliPaySetting) { + AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.getAliPayConfig(); if (relation == null || !"0".equals(relation.getDelFlag()) || relation.getBoundTime() == null || payOrder == null @@ -1296,12 +1285,38 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService || config == null || !ObjectUtil.equals(relation.getChannelAppId(), config.getAppId()) || !ObjectUtil.equals(relation.getChannelSubMchId(), aliPaySetting == null ? null : aliPaySetting.getSubMchid()) + || relation.getFzRatio() == null || !Double.isFinite(relation.getFzRatio()) + || relation.getFzRatio() <= 0 || relation.getFzRatio() > 100 || StrUtil.hasBlank(relation.getReceiverAccount(), relation.getReceiverType())) { throw new IllegalStateException("支付宝支付订单的固定直付通分佣关系不存在或与二级商户不一致"); } validateRoyaltyAccountType(relation.getReceiverType()); } + /** + * 校验新支付只能使用当前支付配置唯一默认且已绑定的关系。 + * + *

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

+ * + * @param relation 内部关系主键查到的关系事实 + * @param payOrder 即将创建的支付订单 + * @param aliPaySetting 当前支付宝配置 + * @param setting 当前商户支付配置 + */ + private void requirePaymentCreateRelation(ThirdMchFzRelationEntity relation, + ThirdPayOrderEntity payOrder, + MchPaySettingEntity.AliPaySetting aliPaySetting, + MchPaySettingVo setting) { + requireDirectPayRelation(relation, payOrder, aliPaySetting); + if (!ObjectUtil.equals(relation.getId(), payOrder.getFzRelationId()) + || !ObjectUtil.equals(relation.getPaySettingId(), setting == null ? null : setting.getId()) + || !ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(relation.getStatus()) + || !com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode().equals(relation.getIsDefault())) { + throw new IllegalStateException("支付宝新支付只能使用当前支付配置默认且已绑定的分佣关系"); + } + } + /** * 将平台抽佣响应映射为本地阶段状态,结算状态传播和限频按可重试处理。 * @@ -1322,14 +1337,15 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService /** * 使用交易号和稳定抽佣请求号查询支付宝分账结果。 - * @param payOrder 支付订单 + * + * @param payOrder 支付订单 * @param aliPaySetting 支付订单创建时保存的支付宝配置 - * @param outRequestNo 平台抽佣稳定请求号 + * @param outRequestNo 平台抽佣稳定请求号 * @return 抽佣查询阶段快照 */ private JSONObject queryDirectPayRoyalty(ThirdPayOrderEntity payOrder, - MchPaySettingEntity.AliPaySetting aliPaySetting, - String outRequestNo) { + MchPaySettingEntity.AliPaySetting aliPaySetting, + String outRequestNo) { JSONObject stage = JSONUtil.createObj(); AlipayTradeOrderSettleQueryModel model = new AlipayTradeOrderSettleQueryModel(); model.setTradeNo(payOrder.getThirdPayNo()); @@ -1339,7 +1355,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService putAppAuthToken(request::putOtherTextParam, aliPaySetting); stage.set("request", JSONUtil.parseObj(JSONUtil.toJsonStr(model))); try { - AlipayTradeOrderSettleQueryResponse response = getAlipayClient(aliPaySetting).execute(request); + AlipayTradeOrderSettleQueryResponse response = getAlipayClient().execute(request); stage.set("response", response == null ? null : JSONUtil.parseObj(JSONUtil.toJsonStr(response))); stage.set("externalStatus", resolveRoyaltyExternalStatus(response)); String status = resolveRoyaltyQueryStatus(response); @@ -1358,6 +1374,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService /** * 将支付宝抽佣查询结果映射为本地阶段状态。 + * * @param response 支付宝抽佣查询响应 * @return SUCCESS、PROCESSING 或 FAILED */ @@ -1381,7 +1398,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService return anyFailed ? "FAILED" : "PROCESSING"; } - /** 返回抽佣查询中最适合监控聚合的外部状态。 */ + /** + * 返回抽佣查询中最适合监控聚合的外部状态。 + */ private String resolveRoyaltyExternalStatus(AlipayTradeOrderSettleQueryResponse response) { if (response == null) { return "EMPTY_RESPONSE"; @@ -1396,7 +1415,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService .filter(StrUtil::isNotBlank).distinct().sorted().collect(java.util.stream.Collectors.joining(",")); } - /** 返回抽佣查询的确定性错误摘要,不包含支付宝账户或授权信息。 */ + /** + * 返回抽佣查询的确定性错误摘要,不包含支付宝账户或授权信息。 + */ private String resolveRoyaltyQueryError(AlipayTradeOrderSettleQueryResponse response) { if (response == null) { return "EMPTY_RESPONSE"; @@ -1414,7 +1435,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService /** * 将支付宝平台抽佣成功状态原子写入支付订单,防止重复轮询累加分账金额。 * - * @param fzOrder 分账订单 + * @param fzOrder 分账订单 * @param payOrder 支付订单 */ private void markDirectPayFzSuccess(ThirdFzOrderEntity fzOrder, ThirdPayOrderEntity payOrder) { @@ -1450,7 +1471,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService * 记录可重试的直付通阶段错误,不把网络异常误判为永久分账失败。 * * @param fzOrder 分账订单 - * @param error 错误摘要 + * @param error 错误摘要 */ private void recordFzError(ThirdFzOrderEntity fzOrder, String error) { if (ObjectUtil.equals(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_SUCCESS.getType())) { @@ -1595,16 +1616,16 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } /** - * 校验支付宝回调应用与支付订单创建时保存的命名账号快照一致。 + * 校验支付宝回调应用与平台统一支付宝账号一致。 * * @param callbackAppId 回调中的支付宝应用 ID - * @param payOrder 回调对应的支付订单 - * @return 回调应用与默认或命名账号完全一致时返回 {@code true} + * @param payOrder 回调对应的支付订单 + * @return 回调应用与平台统一账号完全一致时返回 {@code true} */ private boolean validateCallbackAccount(String callbackAppId, ThirdPayOrderEntity payOrder) { try { - MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySettingSnapshot(payOrder.getMchPaySettingJson()); - AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(aliPaySetting); + parseAliPaySettingSnapshot(payOrder.getMchPaySettingJson()); + AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.getAliPayConfig(); return config != null && ObjectUtil.equals(config.getAppId(), callbackAppId); } catch (Exception exception) { return false; @@ -1649,8 +1670,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService /** * 构建订单码支付的待支付订单,完整记录业务、平台、金额、配置和过期时间。 * - * @param dto 内部支付参数 - * @param setting 商户支付宝配置 + * @param dto 内部支付参数 + * @param setting 商户支付宝配置 * @param expireTime 服务端二维码失效时间 * @return 可直接持久化的待支付订单 */ @@ -1668,24 +1689,27 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService payOrder.setMchId(dto.getMchId()); payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting)); payOrder.setFzRelationId(dto.getFzRelationId()); - payOrder.setEnableFz(setting.getFzRatio() != null && setting.getFzRatio() > 0 + payOrder.setEnableFz(dto.getFzRelationId() != null ? com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode() : com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.NO.getCode()); payOrder.setExpireTime(expireTime); return payOrder; } - /** 支付宝支付底层统一校验抽佣比例与已绑定关系,避免内部调用绕过上层编排。 */ + /** + * 支付宝支付底层统一校验抽佣比例与已绑定关系,避免内部调用绕过上层编排。 + */ private void validateFzRatioAndRelation(ThirdAppPlusPayDto dto, MchPaySettingVo setting) { - if (setting == null || setting.getFzRatio() == null || setting.getFzRatio() == 0) { + if (dto == null || dto.getFzRelationId() == null) { return; } - if (!Double.isFinite(setting.getFzRatio()) || setting.getFzRatio() < 0 || setting.getFzRatio() > 100) { - throw new RuntimeException("支付宝直付通平台抽佣比例必须大于等于0且不超过100%"); - } - if (dto == null || dto.getFzRelationId() == null) { - throw new RuntimeException("支付宝直付通平台分佣关系尚未绑定"); + ThirdMchFzRelationEntity relation = mchFzRelationService == null ? null + : mchFzRelationService.getById(dto.getFzRelationId()); + if (relation == null || relation.getFzRatio() == null || !Double.isFinite(relation.getFzRatio()) + || relation.getFzRatio() <= 0 || relation.getFzRatio() > 100) { + throw new RuntimeException("支付宝直付通平台抽佣关系或比例无效"); } + calculateFzAmount(dto.getAmount(), relation.getFzRatio()); } /** @@ -1775,31 +1799,23 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } /** - * 按回调 app_id 查找默认或命名支付宝账号,避免使用错误公钥验签。 + * 按回调 app_id 核对平台统一支付宝账号,避免使用错误公钥验签。 * * @param appId 支付宝回调中的应用 ID - * @return 与应用 ID 唯一匹配的平台支付宝账号;找不到时返回 {@code null} + * @return 应用 ID 与平台账号一致时返回配置,否则返回 {@code null} */ private AppPlusPayConfig.AliPayConfig resolveCallbackAliPayConfig(String appId) { AppPlusPayConfig.AliPayConfig defaults = appPlusPayConfig.getAliPayConfig(); if (defaults == null || StrUtil.isBlank(appId)) { return null; } - if (ObjectUtil.equals(defaults.getAppId(), appId)) { - return defaults; - } - if (defaults.getAccounts() == null) { - return null; - } - return defaults.getAccounts().values().stream() - .filter(config -> config != null && ObjectUtil.equals(config.getAppId(), appId)) - .findFirst().orElse(null); + return ObjectUtil.equals(defaults.getAppId(), appId) ? defaults : null; } /** * 校验回调应用、商户订单、支付平台及金额均与内部订单一致。 * - * @param params 已通过签名校验的支付宝回调参数 + * @param params 已通过签名校验的支付宝回调参数 * @param payOrder 回调商户订单号对应的内部支付订单 * @return 全部业务约束一致时返回 {@code true} */ @@ -1813,7 +1829,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService log.warn("|{}|支付宝支付回调配置快照无效,payNo:{}", TAG, payNo); return false; } - AppPlusPayConfig.AliPayConfig expectedConfig = resolveAliPayConfig(aliPaySetting); + AppPlusPayConfig.AliPayConfig expectedConfig = appPlusPayConfig.getAliPayConfig(); if (expectedConfig == null || ObjectUtil.notEqual(params.get("app_id"), expectedConfig.getAppId())) { log.warn("|{}|支付宝支付回调appId不匹配,payNo:{}", TAG, payNo); return false; @@ -1850,7 +1866,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService /** * 校验聚合会话与实际第三方子订单的业务、商户和金额快照一致。 * - * @param session {@code pay_no} 绑定的聚合支付会话 + * @param session {@code pay_no} 绑定的聚合支付会话 * @param payOrder 支付宝回调对应的第三方子订单 * @return 关键业务字段完全一致时返回 {@code true} */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java index 890abb45..3a049881 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java @@ -50,7 +50,6 @@ import com.bsd.pjcloud.wallet.api.constant.WalleCardStatusEnum; import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargePaySuccess; import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeRefundSuccess; import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; -import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity; import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargePackageEntity; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; @@ -415,9 +414,7 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { // 微信小程序 if (ObjectUtil.equals(dto.getMiniPlatformType().getType(), AppPlusPlatformTypeEnum.WECHAT.getType())) { MchPaySettingVo paySetting = getEnabledWechatPaySetting(mchManage); - appPlusPayDto.setFzRelationId(resolveFzRelation(paySetting)); - MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class); - appPlusPayDto.setSubMchid(wechatPaySetting.getSubMchid()); + paySetting = applyDefaultPaymentRelation(paySetting, appPlusPayDto); Map appPlusPayParams; try { @@ -434,9 +431,7 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { // 支付宝 else if (ObjectUtil.equals(dto.getMiniPlatformType().getType(), AppPlusPlatformTypeEnum.ALI_PAY.getType())) { MchPaySettingVo paySetting = getEnabledAliPaySetting(mchManage); - appPlusPayDto.setFzRelationId(resolveFzRelation(paySetting)); - MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - appPlusPayDto.setSubMchid(aliPaySetting.getSubMchid()); + paySetting = applyDefaultPaymentRelation(paySetting, appPlusPayDto); String orderStr; try { @@ -593,12 +588,10 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { // 微信 if (ObjectUtil.equals(dto.getMiniPlatformType().getType(), AppPlusPlatformTypeEnum.WECHAT.getType())) { MchPaySettingVo paySetting = paySettingList.stream().filter(item -> ObjectUtil.equals(item.getPlatformType(), AppPlusPlatformPayType.WECHAT.getType())).findFirst().orElse(null); - if (ObjectUtil.isEmpty(paySetting) || ObjectUtil.isEmpty(paySetting.getSettingJson())) { + if (ObjectUtil.isEmpty(paySetting)) { throw new RuntimeException("商户未配置微信支付参数"); } - MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class); - appPlusPayDto.setFzRelationId(resolveFzRelation(paySetting)); - appPlusPayDto.setSubMchid(wechatPaySetting.getSubMchid()); + paySetting = applyDefaultPaymentRelation(paySetting, appPlusPayDto); String wxOutTradeNo = thirdAppPlusWechatMicropayService.micropay(appPlusPayDto, paySetting); if (ObjectUtil.isNotEmpty(wxOutTradeNo)) { @@ -611,9 +604,7 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { if (ObjectUtil.isEmpty(paySetting)) { throw new RuntimeException("商户未配置微信支付参数"); } - MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - appPlusPayDto.setFzRelationId(resolveFzRelation(paySetting)); - appPlusPayDto.setSubMchid(aliPaySetting.getSubMchid()); + applyDefaultPaymentRelation(paySetting, appPlusPayDto); } // 不支持的支付类型 else { @@ -739,12 +730,12 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { LocalDateTime expireTime = LocalDateTime.now().plusMinutes(ORDER_CODE_EXPIRE_MINUTES); if (ObjectUtil.equals(dto.getMiniPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY)) { MchPaySettingVo paySetting = getEnabledAliPaySetting(merchant); - payDto.setFzRelationId(resolveFzRelation(paySetting)); + paySetting = applyDefaultPaymentRelation(paySetting, payDto); validateAliPayApplicationConfig(); return thirdMiniAppAliPayService.precreate(payDto, paySetting, expireTime); } MchPaySettingVo paySetting = getEnabledWechatPaySetting(merchant); - payDto.setFzRelationId(resolveFzRelation(paySetting)); + paySetting = applyDefaultPaymentRelation(paySetting, payDto); validateWechatApplicationConfig(); return thirdAppPlusWechatNativePayService.precreate(payDto, paySetting, expireTime); } @@ -753,22 +744,20 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { * 校验订单码支付首版允许的业务、平台、金额和商户参数。 */ /** - * 解析支付配置对应的已绑定平台分佣关系,未启用抽佣时不写关系主键。 + * 解析默认关系并生成订单级渠道配置快照,二级商户与支付宝授权均不得再从支付配置 JSON 读取。 * * @param paySetting 已由商户服务确认启用的支付配置 - * @return 已绑定关系主键;平台抽佣比例为零时为空 + * @param payDto 即将提交渠道服务的内部支付参数 + * @return 由关系事实生成的独立支付配置快照 */ - private Long resolveFzRelation(MchPaySettingVo paySetting) { + private MchPaySettingVo applyDefaultPaymentRelation(MchPaySettingVo paySetting, ThirdAppPlusPayDto payDto) { if (mchFzRelationService == null) { - // 独立单元测试或历史单体启动未装载关系模块时,不改变无分佣配置的既有支付路径; - // 生产容器必须提供关系服务,启用抽佣时由其严格拒绝未绑定支付。 - if (paySetting == null || paySetting.getFzRatio() == null || paySetting.getFzRatio() <= 0) { - return null; - } throw new IllegalStateException("支付平台分佣关系服务不可用"); } com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity relation = mchFzRelationService.requireBound(paySetting); - return relation == null ? null : relation.getId(); + payDto.setFzRelationId(relation.getId()); + payDto.setSubMchid(relation.getChannelSubMchId()); + return mchFzRelationService.createPaymentSettingSnapshot(paySetting, relation); } private void validateOrderCodePayRequest(ThirdOrderCodePayDto dto) { @@ -888,20 +877,9 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { .filter(item -> ObjectUtil.equals(item.getEnable(), YesNoEnum.YES.getCode())) .findFirst() .orElse(null); - if (paySetting == null || StrUtil.isBlank(paySetting.getSettingJson())) { + if (paySetting == null) { throw new RuntimeException("商户未启用支付宝支付配置"); } - try { - MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - if (aliPaySetting == null || StrUtil.isBlank(aliPaySetting.getSubMchid())) { - throw new RuntimeException("商户支付宝直付通二级商户标识未配置"); - } - } catch (RuntimeException exception) { - if ("商户支付宝直付通二级商户标识未配置".equals(exception.getMessage())) { - throw exception; - } - throw new RuntimeException("商户支付宝支付配置无效"); - } return paySetting; } @@ -914,21 +892,9 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { .filter(item -> ObjectUtil.equals(item.getEnable(), YesNoEnum.YES.getCode())) .findFirst() .orElse(null); - if (paySetting == null || StrUtil.isBlank(paySetting.getSettingJson())) { + if (paySetting == null) { throw new RuntimeException("商户未启用微信支付配置"); } - try { - MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean( - paySetting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class); - if (wechatPaySetting == null || StrUtil.isBlank(wechatPaySetting.getSubMchid())) { - throw new RuntimeException("商户微信子商户号未配置"); - } - } catch (RuntimeException exception) { - if ("商户微信子商户号未配置".equals(exception.getMessage())) { - throw exception; - } - throw new RuntimeException("商户微信支付配置无效"); - } return paySetting; } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatMicropayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatMicropayServiceImpl.java index 58b7320a..31cc74ba 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatMicropayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatMicropayServiceImpl.java @@ -14,6 +14,7 @@ import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatMicropayService; import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatService; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer; +import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService; import com.bsd.pjcloud.thirdparty.utils.pay.PayForUtil; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; @@ -27,6 +28,7 @@ import com.wechat.pay.java.service.partnerpayments.app.model.Transaction; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.validation.annotation.Validated; import java.time.LocalDateTime; @@ -53,6 +55,10 @@ public class ThirdAppPlusWechatMicropayServiceImpl implements ThirdAppPlusWechat /** 支付成功后写入数据库分账任务,不创建进程内延迟线程。 */ private final ThirdPaymentTaskEnqueuer paymentTaskEnqueuer; + /** 付款码创建时校验支付订单固定的关系归属、状态和比例。 */ + @Autowired(required = false) + private ThirdMchFzRelationService mchFzRelationService; + /** * 付款码支付 */ @@ -86,11 +92,15 @@ public class ThirdAppPlusWechatMicropayServiceImpl implements ThirdAppPlusWechat request.setSpbillCreateIp(PayForUtil.localIp()); request.setAuthCode(dto.getDevicePayCode()); request.setProfitSharing("N"); - // 是否需要分账 - if (dto.getAmount() > 10 && ObjectUtil.isNotEmpty(setting) && ObjectUtil.isNotEmpty(setting.getFzRatio()) && setting.getFzRatio() > 0) { - if (dto.getFzRelationId() == null) { - throw new RuntimeException("微信平台分佣关系尚未绑定"); + // 分账由关系主键唯一启用,金额过小不能静默降级为普通支付。 + if (dto.getFzRelationId() != null) { + if (dto.getAmount() == null || dto.getAmount() <= 10) { + throw new RuntimeException("微信分账支付金额必须大于10分"); } + if (mchFzRelationService == null) { + throw new RuntimeException("微信平台分佣关系服务不可用"); + } + mchFzRelationService.requirePaymentRelation(dto.getFzRelationId(), setting); request.setProfitSharing("Y"); payOrder.setEnableFz(YesNoEnum.YES.getCode()); } else { diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatNativePayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatNativePayServiceImpl.java index ea942259..53a3f294 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatNativePayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatNativePayServiceImpl.java @@ -8,7 +8,9 @@ import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatNativePayService; +import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; @@ -25,6 +27,7 @@ import com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.validation.annotation.Validated; import java.time.LocalDateTime; @@ -72,6 +75,10 @@ public class ThirdAppPlusWechatNativePayServiceImpl implements ThirdAppPlusWecha */ private final ThirdPayOrderService thirdPayOrderService; + /** 支付创建时校验关系主键归属、绑定状态和抽佣比例。 */ + @Autowired(required = false) + private ThirdMchFzRelationService mchFzRelationService; + /** * 创建微信服务商 Native 订单,只有 SDK 返回非空 {@code code_url} 才视为成功。 * @@ -100,9 +107,12 @@ public class ThirdAppPlusWechatNativePayServiceImpl implements ThirdAppPlusWecha request.setNotifyUrl(wechatConfig.getBaseCallbackUrl() + wechatConfig.getPayCallbackUrl()); request.setAmount(amount); SettleInfo settleInfo = new SettleInfo(); - boolean enableFz = setting.getFzRatio() != null && setting.getFzRatio() > 0; - if (enableFz && dto.getFzRelationId() == null) { - throw new RuntimeException("微信平台分佣关系尚未绑定"); + boolean enableFz = dto.getFzRelationId() != null; + if (enableFz) { + if (mchFzRelationService == null) { + throw new RuntimeException("微信平台分佣关系服务不可用"); + } + mchFzRelationService.requirePaymentRelation(dto.getFzRelationId(), setting); } settleInfo.setProfitSharing(enableFz); request.setSettleInfo(settleInfo); @@ -209,7 +219,7 @@ public class ThirdAppPlusWechatNativePayServiceImpl implements ThirdAppPlusWecha payOrder.setMchId(dto.getMchId()); payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting)); payOrder.setFzRelationId(dto.getFzRelationId()); - payOrder.setEnableFz(setting.getFzRatio() != null && setting.getFzRatio() > 0 + payOrder.setEnableFz(dto.getFzRelationId() != null ? com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode() : com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.NO.getCode()); payOrder.setExpireTime(expireTime); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImpl.java index a16db618..d24d7681 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImpl.java @@ -19,11 +19,13 @@ import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.*; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService; import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatService; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; import com.bsd.pjcloud.thirdparty.service.ThirdPaymentOrderReservationService; import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer; +import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService; import com.bsd.pjcloud.thirdparty.service.impl.appplus.fz.lifecycle.ThirdAppPlusWechatFzLifecycle; import com.bsd.pjcloud.thirdparty.utils.pay.wechat.ThirdAppPlusWechantPayConstant; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.wechat.*; @@ -93,6 +95,9 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService @Autowired(required = false) private ThirdPaymentOrderReservationService paymentOrderReservationService; + /** 支付创建时校验关系归属和比例;属于支付安全前置条件,禁止以可选依赖方式绕过。 */ + private final ThirdMchFzRelationService mchFzRelationService; + /** * 创建微信服务商 App 支付,子商户号和分账比例只接受服务端商户配置快照。 * @@ -130,10 +135,7 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService SettleInfo settleInfo = new SettleInfo(); settleInfo.setProfitSharing(false); // 是否需要分账 - if (ObjectUtil.isNotEmpty(setting.getFzRatio()) && setting.getFzRatio() > 0) { - if (dto.getFzRelationId() == null) { - throw new RuntimeException("微信平台分佣关系尚未绑定"); - } + if (dto.getFzRelationId() != null) { settleInfo.setProfitSharing(true); payOrder.setEnableFz(YesNoEnum.YES.getCode()); } else { @@ -198,19 +200,19 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService if (wechatPaySetting == null || StrUtil.isBlank(wechatPaySetting.getSubMchid())) { throw new RuntimeException("微信支付子商户号未配置"); } - if (setting.getFzRatio() == null || setting.getFzRatio() <= 0) { + if (dto.getFzRelationId() == null) { return wechatPaySetting; } - if (setting.getFzRatio() > 100) { - throw new RuntimeException("微信平台抽佣比例必须大于0且不超过100%"); - } + ThirdMchFzRelationEntity relation = mchFzRelationService.requirePaymentRelation(dto.getFzRelationId(), setting); if (dto.getAmount() <= 10) { throw new RuntimeException("微信分账支付金额必须大于10分"); } long fzAmount = BigDecimal.valueOf(dto.getAmount()) - .multiply(BigDecimal.valueOf(setting.getFzRatio())) + .multiply(BigDecimal.valueOf(relation.getFzRatio())) .divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN).longValueExact(); - if (fzAmount < 1L || fzAmount > dto.getAmount()) { + // 微信分账单笔金额至少1分,低比例向下取整为0时按1分提交。 + fzAmount = Math.max(1L, fzAmount); + if (fzAmount > dto.getAmount()) { throw new RuntimeException("微信平台抽佣金额必须不少于1分且不能超过订单金额"); } return wechatPaySetting; diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/fz/ThirdAppPlusWechatFzServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/fz/ThirdAppPlusWechatFzServiceImpl.java index 125e7336..43731e6f 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/fz/ThirdAppPlusWechatFzServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/fz/ThirdAppPlusWechatFzServiceImpl.java @@ -59,9 +59,8 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ */ private final ThirdPaymentTaskEnqueuer paymentTaskEnqueuer; - /** 支付订单创建时固定的平台分佣关系快照。 */ - @org.springframework.beans.factory.annotation.Autowired(required = false) - private ThirdMchFzRelationService mchFzRelationService; + /** 支付订单创建时固定的平台分佣关系服务;分账和退分账必须依赖该历史关系事实。 */ + private final ThirdMchFzRelationService mchFzRelationService; /** * 发起分账 @@ -91,11 +90,6 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ } MchPaySettingEntity mchPaySetting = JSONUtil.toBean(orderEntity.getMchPaySettingJson(), MchPaySettingEntity.class); MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean(mchPaySetting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class); - if (mchPaySetting.getFzRatio() == null || mchPaySetting.getFzRatio() <= 0 - || mchPaySetting.getFzRatio() > 100) { - throw new RuntimeException("微信平台抽佣比例必须大于0且不超过100%"); - } - String outOrderNo = "fz" + orderEntity.getPayNo(); ThirdFzOrderEntity fzOrderEntity = new ThirdFzOrderEntity(); @@ -107,17 +101,21 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ fzOrderEntity.setStatus(ThirdFzOrderStatusEnum.FZ_UNPAID.getType()); AppPlusPayConfig.WechatConfig wechatConfig = appPlusPayConfig.getWechatConfig(); - ThirdMchFzRelationEntity relation = mchFzRelationService == null ? null - : mchFzRelationService.getById(orderEntity.getFzRelationId()); + ThirdMchFzRelationEntity relation = mchFzRelationService.getById(orderEntity.getFzRelationId()); requireWechatRelation(relation, orderEntity, wechatPaySetting.getSubMchid(), wechatConfig); + if (relation.getFzRatio() == null || !Double.isFinite(relation.getFzRatio()) || relation.getFzRatio() <= 0 || relation.getFzRatio() > 100) { + throw new RuntimeException("微信平台抽佣关系或比例无效"); + } String receiverAccount = relation.getReceiverAccount(); String receiverName = StrUtil.blankToDefault(relation.getReceiverName(), "浙江资安信信息科技有限公司"); // 最少分账1分 Long amount = BigDecimal.valueOf(orderEntity.getPrice()) - .multiply(BigDecimal.valueOf(mchPaySetting.getFzRatio())) + .multiply(BigDecimal.valueOf(relation.getFzRatio())) .divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN).longValueExact(); - if (amount < 1L || amount > orderEntity.getPrice()) { + // 微信分账单笔金额至少1分,低比例向下取整为0时按1分提交。 + amount = Math.max(1L, amount); + if (amount > orderEntity.getPrice()) { throw new RuntimeException("微信平台抽佣金额必须不少于1分且不能超过订单金额"); } @@ -135,7 +133,7 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ zaxReceiver.setAccount(receiverAccount); zaxReceiver.setName(receiverName); zaxReceiver.setAmount(amount); - zaxReceiver.setDescription(getDescription(mchPaySetting, amount)); + zaxReceiver.setDescription(getDescription(mchPaySetting, relation.getFzRatio(), amount)); createDto.setReceivers(List.of(zaxReceiver)); createDto.setUnfreezeUnsplit(true); fzOrderEntity.setPrice(BigDecimal.valueOf(amount).intValue()); @@ -192,8 +190,7 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ MchPaySettingEntity mchPaySetting = JSONUtil.toBean(orderEntity.getMchPaySettingJson(), MchPaySettingEntity.class); MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean(mchPaySetting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class); - ThirdMchFzRelationEntity relation = mchFzRelationService == null ? null - : mchFzRelationService.getById(orderEntity.getFzRelationId()); + ThirdMchFzRelationEntity relation = mchFzRelationService.getById(orderEntity.getFzRelationId()); requireWechatRelation(relation, orderEntity, wechatPaySetting.getSubMchid(), appPlusPayConfig.getWechatConfig()); String outOrderNo = "refund_" + fzOrderDetail.getFzNo(); @@ -204,7 +201,7 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ returnDto.setOutReturnNo(outOrderNo); returnDto.setReturnMchid(relation.getReceiverAccount()); returnDto.setAmount((long) fzOrderDetail.getPrice()); - returnDto.setDescription("退回" + getDescription(mchPaySetting, returnDto.getAmount())); + returnDto.setDescription("退回" + getDescription(mchPaySetting, relation.getFzRatio(), returnDto.getAmount())); ThirdFzOrderEntity updateObj = new ThirdFzOrderEntity(); updateObj.setFzRefundTime(LocalDateTime.now()); @@ -607,8 +604,8 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ } // 分账描述,最多80个字 - private String getDescription(MchPaySettingEntity mchPaySetting, Long amount) { - return "商户id:" + mchPaySetting.getMchId() + "分账" + mchPaySetting.getFzRatio() + "%即" + fenToYuan(BigDecimal.valueOf(amount)) + "元,到平台"; + private String getDescription(MchPaySettingEntity mchPaySetting, Double fzRatio, Long amount) { + return "商户id:" + mchPaySetting.getMchId() + "分账" + fzRatio + "%即" + fenToYuan(BigDecimal.valueOf(amount)) + "元,到平台"; } public String fenToYuan(BigDecimal fen) { diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java index 959672cc..f6a10712 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java @@ -24,8 +24,7 @@ import java.util.List; */ @Service @Validated -public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl - implements ThirdAggregatePaySessionService { +public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl implements ThirdAggregatePaySessionService { /** * 按聚合会话编号读取当前租户有效记录。 @@ -76,7 +75,7 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl * * @param sessionId 聚合会话主键 - * @param status 目标终态 + * @param status 目标终态 * @return CAS 更新成功时返回 {@code true} */ @Override diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java index 29cc8398..ced357d0 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java @@ -1,5 +1,6 @@ package com.bsd.pjcloud.thirdparty.service.impl.pay; +import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; import com.alipay.api.AlipayClient; @@ -23,10 +24,12 @@ import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationSceneEnum; import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationStatusEnum; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdMchFzRelationSaveDto; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdMchFzRelationMapper; import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService; import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdMchFzRelationVo; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; @@ -44,9 +47,10 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.dao.DuplicateKeyException; import java.time.LocalDateTime; -import java.util.ArrayList; +import java.math.BigDecimal; import java.util.List; import java.util.Objects; +import java.util.Set; /** * 商户微信/支付宝默认平台分佣关系实现。 @@ -59,28 +63,414 @@ import java.util.Objects; @RequiredArgsConstructor public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService { - /** 微信支付平台持久化编码。 */ + /** + * 微信支付平台持久化编码。 + */ private static final String WECHAT_TYPE = AppPlusPlatformPayType.WECHAT.getType(); - /** 支付宝支付平台持久化编码。 */ + /** + * 支付宝支付平台持久化编码。 + */ private static final String ALIPAY_TYPE = AppPlusPlatformPayType.ALI_PAY.getType(); - /** 关系绑定的固定业务场景。 */ + /** + * 关系绑定的固定业务场景。 + */ private static final String DEFAULT_SCENE = ThirdMchFzRelationSceneEnum.DEFAULT_PLATFORM_COMMISSION.getCode(); - /** 本地关系绑定任务的优先级,需早于支付创建后的分账任务。 */ + /** + * 本地关系绑定任务的优先级,需早于支付创建后的分账任务。 + */ private static final int RELATION_TASK_PRIORITY = 150; + /** + * 支付宝关系绑定幂等号的最大长度,渠道接口要求不超过32个字符。 + */ + private static final int ALIPAY_OUT_REQUEST_NO_MAX_LENGTH = 32; + /** + * 管理端允许配置的支付宝接收方类型,编码直接传给支付宝 SDK。 + */ + private static final Set ALIPAY_RECEIVER_TYPES = Set.of("userId", "loginName", "openId", "cardAliasNo"); + /** + * 管理端允许配置的微信接收方类型,编码对应微信 V3 分账 SDK 枚举。 + */ + private static final Set WECHAT_RECEIVER_TYPES = Set.of("MERCHANT_ID", "PERSONAL_OPENID", "PERSONAL_SUB_OPENID"); - /** 关系事实 Mapper。 */ + /** + * 关系事实 Mapper。 + */ private final ThirdMchFzRelationMapper relationMapper; - /** 关系绑定任务写入器。 */ + /** + * 关系绑定任务写入器。 + */ private final ThirdPaymentTaskEnqueuer taskEnqueuer; - /** 微信服务商分账 SDK。 */ + /** + * 微信服务商分账 SDK。 + */ private final ProfitsharingService profitsharingService; - /** 应用级支付宝、微信平台配置。 */ + /** + * 应用级支付宝、微信平台配置。 + */ private final AppPlusPayConfig appPlusPayConfig; - /** 钱包商户内部接口,用于 Worker 执行时读取当前支付宝授权 token。 */ + /** + * 钱包商户内部接口,用于校验支付配置和管理端商户归属。 + */ private final RemoteMchService remoteMchService; /** - * 物化当前商户启用且配置抽佣比例的默认关系,并把旧配置标记为历史关系。 + * 查询支付配置下的一对多关系,接收方账号只返回脱敏值。 + * + * @param mchId 商户主键 + * @param paySettingId 支付配置主键 + * @return 默认关系优先的关系列表 + */ + @Override + public List listForManage(Long mchId, Long paySettingId) { + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId == null || mchId == null || paySettingId == null) { + throw new IllegalArgumentException("商户、支付配置和租户不能为空"); + } + MchManageVo merchant = requireMerchant(mchId); + if (merchant.getPaySettingList() == null || merchant.getPaySettingList().stream() + .noneMatch(item -> paySettingId.equals(item.getId()))) { + throw new IllegalArgumentException("支付配置不属于指定商户"); + } + return relationMapper.selectList(new LambdaQueryWrapper() + .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) + .eq(ThirdMchFzRelationEntity::getMchId, mchId) + .eq(ThirdMchFzRelationEntity::getPaySettingId, paySettingId) + .eq(ThirdMchFzRelationEntity::getRelationScene, DEFAULT_SCENE) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0") + .orderByDesc(ThirdMchFzRelationEntity::getIsDefault) + .orderByDesc(ThirdMchFzRelationEntity::getCreateTime)) + .stream().map(this::toManageVo).toList(); + } + + /** + * 新增或编辑关系配置。渠道身份变化时创建新关系版本,非身份渠道字段变化时重置原关系并重新绑定。 + * + *

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

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

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

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

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

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

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

+ * + * @param platformType 支付平台持久化编码 + * @param relationId 关系主键 + * @return 不超过支付宝渠道长度限制的绑定幂等号 + */ + private String newRelationRequestNo(String platformType, Long relationId) { + String prefix = ALIPAY_TYPE.equals(platformType) ? "ar_" : "wr_"; + String relationPart = relationId == null ? "0" : Long.toUnsignedString(relationId, 36); + String requestPart = Long.toUnsignedString(IdWorker.getId(), 36); + String requestNo = prefix + relationPart + "_" + requestPart; + // 当前两个无符号Long的36进制表示最多各13位,正常情况下总长度最多30位;保留兜底保证渠道约束。 + if (requestNo.length() > ALIPAY_OUT_REQUEST_NO_MAX_LENGTH) { + requestNo = prefix + requestPart; + } + return requestNo; + } + + /** + * 构造可持久化的脱敏绑定请求快照,不记录完整接收账号或授权令牌。 + * + * @param relation 将由 Worker 提交渠道的关系版本 + * @return 脱敏 JSON 快照 + */ + private String buildBindRequestSnapshot(ThirdMchFzRelationEntity relation) { + return JSONUtil.createObj() + .set("platform", ALIPAY_TYPE.equals(relation.getPlatformType()) ? "ALIPAY" : "WECHAT") + .set("receiverType", relation.getReceiverType()) + .set("receiverAccount", mask(relation.getReceiverAccount())) + .set("receiverName", relation.getReceiverName()) + .set("channelSubMchId", relation.getChannelSubMchId()) + .set("channelRelationType", relation.getChannelRelationType()) + .set("accountKey", relation.getAccountKey()) + .set("appAuthTokenConfigured", StrUtil.isNotBlank(relation.getAppAuthToken())).toString(); + } + + /** + * 管理端接收方类型、账号及微信关系类型校验。 + */ + private void validateManageReceiver(ThirdMchFzRelationSaveDto dto, MchPaySettingVo setting) { + String receiverType = StrUtil.trim(dto.getReceiverType()); + String account = StrUtil.trim(dto.getReceiverAccount()); + if (StrUtil.isBlank(receiverType) || StrUtil.isBlank(account)) { + throw new IllegalArgumentException("接收方类型和标识不能为空"); + } + if (StrUtil.isBlank(dto.getChannelSubMchId())) { + throw new IllegalArgumentException(WECHAT_TYPE.equals(setting.getPlatformType()) + ? "微信子商户号不能为空" : "支付宝直付通二级商户标识不能为空"); + } + if (ALIPAY_TYPE.equals(setting.getPlatformType()) && !ALIPAY_RECEIVER_TYPES.contains(receiverType)) { + throw new IllegalArgumentException("支付宝接收方只能是userId、loginName、openId或cardAliasNo"); + } + if (WECHAT_TYPE.equals(setting.getPlatformType()) && !WECHAT_RECEIVER_TYPES.contains(receiverType)) { + throw new IllegalArgumentException("微信接收方类型不受支持"); + } + if (WECHAT_TYPE.equals(setting.getPlatformType()) && StrUtil.isBlank(dto.getChannelRelationType())) { + throw new IllegalArgumentException("微信分账关系类型不能为空"); + } + if (WECHAT_TYPE.equals(setting.getPlatformType())) { + try { + ReceiverRelationType.valueOf(dto.getChannelRelationType()); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("微信分账关系类型不受支持"); + } + } + } + + /** + * 校验关系渠道 AppID 仍与平台统一配置一致;二级商户和授权上下文本身以关系事实为准。 + */ + private boolean matchesCurrentSetting(ThirdMchFzRelationEntity relation, MchPaySettingVo setting) { + if (!Objects.equals(relation.getPlatformType(), setting.getPlatformType())) { + return false; + } + if (WECHAT_TYPE.equals(setting.getPlatformType())) { + return Objects.equals(relation.getChannelAppId(), requireWechatConfig().getAppPlatformAppId()); + } + return Objects.equals(relation.getChannelAppId(), requireAliPayConfig().getAppId()); + } + + /** + * 仅构造关系拥有的渠道应用、二级商户和支付宝授权上下文。 + */ + private ThirdMchFzRelationEntity buildChannelRelation(Long tenantId, ThirdMchFzRelationSaveDto dto, + MchPaySettingVo setting) { + if (tenantId == null || dto == null || dto.getMchId() == null || setting == null || setting.getId() == null) { throw new IllegalArgumentException("商户支付配置关系参数不完整"); } ThirdMchFzRelationEntity result = new ThirdMchFzRelationEntity(); result.setId(IdWorker.getId()); result.setTenantId(tenantId); - result.setMchId(mchId); + result.setMchId(dto.getMchId()); result.setPaySettingId(setting.getId()); result.setPlatformType(setting.getPlatformType()); result.setRelationScene(DEFAULT_SCENE); - result.setIsDefault(YesNoEnum.YES.getCode()); + // 默认关系只承载渠道接收方身份,抽佣比例必须由关系管理明确配置。 + result.setFzRatio(0D); + result.setIsDefault(YesNoEnum.NO.getCode()); result.setStatus(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode()); result.setDelFlag("0"); result.setCreateTime(LocalDateTime.now()); result.setUpdateTime(result.getCreateTime()); if (WECHAT_TYPE.equals(setting.getPlatformType())) { AppPlusPayConfig.WechatConfig config = requireWechatConfig(); - MchPaySettingEntity.WechatPaySetting paySetting = parseWechat(setting); - String receiverAccount = StrUtil.blankToDefault(config.getProfitSharingReceiverAccount(), config.getMerchantId()); - String receiverName = StrUtil.blankToDefault(config.getProfitSharingReceiverName(), "浙江资安信信息科技有限公司"); result.setChannelAppId(config.getAppPlatformAppId()); - result.setChannelSubMchId(paySetting.getSubMchid()); - result.setReceiverType(ReceiverType.MERCHANT_ID.name()); - result.setReceiverAccount(receiverAccount); - result.setReceiverName(receiverName); + result.setChannelSubMchId(StrUtil.trim(dto.getChannelSubMchId())); result.setChannelRelationType(ReceiverRelationType.SERVICE_PROVIDER.name()); - result.setOutRequestNo("wxrel_" + result.getId()); - result.setBindReq(JSONUtil.createObj().set("platform", "WECHAT").set("appid", config.getAppPlatformAppId()) - .set("subMchid", paySetting.getSubMchid()).set("receiverType", ReceiverType.MERCHANT_ID.name()) - .set("receiverAccount", mask(receiverAccount)).toString()); } else if (ALIPAY_TYPE.equals(setting.getPlatformType())) { - AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(setting); - MchPaySettingEntity.AliPaySetting paySetting = parseAliPay(setting); - String receiverType = StrUtil.blankToDefault(config.getRoyaltyTransInType(), "userId"); - String receiverAccount = StrUtil.blankToDefault(config.getRoyaltyTransIn(), ""); - if (StrUtil.isBlank(receiverAccount)) { - throw new IllegalStateException("平台支付宝分佣接收账号未配置"); - } - if (!List.of("userId", "loginName").contains(receiverType)) { - throw new IllegalStateException("支付宝分佣接收方类型只支持userId或loginName"); - } - result.setChannelAppId(config.getAppId()); - result.setChannelSubMchId(paySetting.getSubMchid()); - result.setAccountKey(paySetting.getAccountKey()); - result.setReceiverType(receiverType); - result.setReceiverAccount(receiverAccount); - result.setReceiverName(config.getRoyaltyTransInName()); - result.setOutRequestNo("alirel_" + result.getId()); - result.setBindReq(JSONUtil.createObj().set("platform", "ALIPAY").set("appId", config.getAppId()) - .set("smid", paySetting.getSubMchid()).set("receiverType", receiverType) - .set("receiverAccount", mask(receiverAccount)).set("appAuthTokenConfigured", StrUtil.isNotBlank(paySetting.getAppAuthToken())).toString()); + String accountKey = StrUtil.trim(dto.getAccountKey()); + result.setChannelAppId(requireAliPayConfig().getAppId()); + result.setChannelSubMchId(StrUtil.trim(dto.getChannelSubMchId())); + result.setAccountKey(StrUtil.isBlank(accountKey) ? null : accountKey); + String appAuthToken = StrUtil.trim(dto.getAppAuthToken()); + result.setAppAuthToken(StrUtil.isBlank(appAuthToken) ? null : appAuthToken); } else { throw new IllegalArgumentException("不支持的支付平台关系"); } return result; } - /** 判断支付配置是否需要平台分佣关系。 */ + /** + * 设定一个关系为支付配置默认关系,并将其他关系降为历史关系但不删除渠道事实。 + */ + private void setDefault(ThirdMchFzRelationEntity relation) { + relationMapper.update(null, new LambdaUpdateWrapper() + .eq(ThirdMchFzRelationEntity::getTenantId, relation.getTenantId()) + .eq(ThirdMchFzRelationEntity::getPaySettingId, relation.getPaySettingId()) + .eq(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode()) + .ne(ThirdMchFzRelationEntity::getId, relation.getId()) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0") + .set(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.NO.getCode())); + relationMapper.update(null, new LambdaUpdateWrapper() + .eq(ThirdMchFzRelationEntity::getId, relation.getId()) + .eq(ThirdMchFzRelationEntity::getTenantId, relation.getTenantId()) + .set(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode())); + relation.setIsDefault(YesNoEnum.YES.getCode()); + } + + /** + * 钱包商户查询,关系管理接口不信任前端提交的商户支付配置。 + */ + private MchManageVo requireMerchant(Long mchId) { + R response = remoteMchService.getById(mchId); + if (response == null || !response.isOk() || response.getData() == null + || !Objects.equals(response.getData().getId(), mchId)) { + throw new IllegalArgumentException("商户不存在或当前不可用"); + } + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId != null && response.getData().getTenantId() != null + && !tenantId.equals(response.getData().getTenantId())) { + throw new IllegalArgumentException("商户不属于当前租户"); + } + return response.getData(); + } + + /** + * 关系管理视图,绝不返回完整接收账号。 + */ + private ThirdMchFzRelationVo toManageVo(ThirdMchFzRelationEntity relation) { + ThirdMchFzRelationVo vo = new ThirdMchFzRelationVo(); + vo.setId(relation.getId()); + vo.setMchId(relation.getMchId()); + vo.setPaySettingId(relation.getPaySettingId()); + vo.setPlatformType(relation.getPlatformType()); + vo.setChannelSubMchId(relation.getChannelSubMchId()); + vo.setAccountKey(relation.getAccountKey()); + vo.setAppAuthTokenConfigured(StrUtil.isNotBlank(relation.getAppAuthToken())); + vo.setReceiverType(relation.getReceiverType()); + vo.setReceiverAccount(mask(relation.getReceiverAccount())); + vo.setReceiverName(relation.getReceiverName()); + vo.setFzRatio(relation.getFzRatio()); + vo.setChannelRelationType(relation.getChannelRelationType()); + vo.setStatus(relation.getStatus()); + vo.setIsDefault(relation.getIsDefault()); + vo.setBoundTime(relation.getBoundTime()); + vo.setLastExternalStatus(relation.getLastExternalStatus()); + vo.setBindError(relation.getBindError()); + vo.setLastQueryTime(relation.getLastQueryTime()); + return vo; + } + + /** + * 判断支付配置是否具备创建平台分佣关系的渠道条件;抽佣比例由关系记录决定。 + */ private boolean isEligible(MchPaySettingVo setting) { return setting != null && setting.getId() != null && YesNoEnum.YES.getCode().equals(setting.getEnable()) - && List.of(WECHAT_TYPE, ALIPAY_TYPE).contains(setting.getPlatformType()) - && setting.getFzRatio() != null && setting.getFzRatio() > 0 && setting.getFzRatio() <= 100; + && List.of(WECHAT_TYPE, ALIPAY_TYPE).contains(setting.getPlatformType()); } - /** 校验已启用微信/支付宝配置的抽佣比例;零值表示关闭抽佣,负数或大于100属于配置错误。 */ - private void validateFzRatio(MchPaySettingVo setting) { - if (setting == null || !YesNoEnum.YES.getCode().equals(setting.getEnable()) - || !List.of(WECHAT_TYPE, ALIPAY_TYPE).contains(setting.getPlatformType()) - || setting.getFzRatio() == null) { + /** + * 校验关系抽佣比例;新增关系必须启用抽佣,已有或自动关系允许零值关闭抽佣。 + */ + private void validateRelationRatio(Double ratio, boolean required) { + if (ratio == null) { + if (required) { + throw new IllegalArgumentException("平台抽佣比例不能为空"); + } return; } - if (!Double.isFinite(setting.getFzRatio()) || setting.getFzRatio() < 0 || setting.getFzRatio() > 100) { + if (!Double.isFinite(ratio) || ratio < 0 || ratio > 100) { throw new IllegalArgumentException("平台抽佣比例必须大于等于0且不超过100%"); } + // 数据库关系字段精确到0.001%,接口层先拒绝更高精度,避免落库时静默舍入导致抽佣金额与页面配置不一致。 + if (BigDecimal.valueOf(ratio).scale() > 3) { + throw new IllegalArgumentException("平台抽佣比例最多支持三位小数(0.001%)"); + } + if (required && ratio == 0) { + throw new IllegalArgumentException("平台抽佣比例必须大于0且不超过100%"); + } } - /** 微信关系绑定。 */ + /** + * 校验关系的当前渠道归属、比例与绑定状态,并在尚未绑定时确保恢复任务存在。 + */ + private ThirdMchFzRelationEntity validatePaymentRelation(ThirdMchFzRelationEntity relation, + MchPaySettingVo setting, Long tenantId) { + if (relation == null || !matchesCurrentSetting(relation, setting) + || StrUtil.hasBlank(relation.getChannelAppId(), relation.getChannelSubMchId(), + relation.getReceiverType(), relation.getReceiverAccount())) { + throw new IllegalStateException("支付平台分佣关系与当前商户渠道配置不一致"); + } + validateRelationRatio(relation.getFzRatio(), true); + if (relation.getFzRatio() <= 0) { + throw new IllegalStateException("支付平台分佣关系未启用抽佣"); + } + if (!ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(relation.getStatus())) { + taskEnqueuer.ensureRelationBind(tenantId, relation.getId(), LocalDateTime.now(), RELATION_TASK_PRIORITY); + throw new IllegalStateException("支付平台分佣关系尚未绑定,请稍后重试"); + } + return relation; + } + + /** + * 微信关系绑定。 + */ private boolean bindWechat(ThirdMchFzRelationEntity relation) { AddReceiverRequest request = new AddReceiverRequest(); request.setAppid(relation.getChannelAppId()); request.setSubMchid(relation.getChannelSubMchId()); - request.setType(ReceiverType.MERCHANT_ID); + request.setType(ReceiverType.valueOf(relation.getReceiverType())); request.setAccount(relation.getReceiverAccount()); - request.setName(StrUtil.blankToDefault(relation.getReceiverName(), "浙江资安信信息科技有限公司")); - request.setRelationType(ReceiverRelationType.SERVICE_PROVIDER); + if (StrUtil.isNotBlank(relation.getReceiverName())) { + request.setName(relation.getReceiverName()); + } + request.setRelationType(ReceiverRelationType.valueOf(relation.getChannelRelationType())); try { AddReceiverResponse response = profitsharingService.addReceiver(request); if (response == null) { @@ -436,23 +954,20 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService } } - /** 支付宝关系绑定。 */ + /** + * 支付宝关系绑定。 + */ private boolean bindAlipay(ThirdMchFzRelationEntity relation) throws Exception { MchPaySettingEntity.AliPaySetting paySetting = loadAliPaySetting(relation); - AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(paySetting); - if (!Objects.equals(relation.getChannelAppId(), config.getAppId()) - || !Objects.equals(relation.getChannelSubMchId(), paySetting.getSubMchid())) { + AppPlusPayConfig.AliPayConfig config = requireAliPayConfig(); + if (!Objects.equals(relation.getChannelAppId(), config.getAppId()) || !Objects.equals(relation.getChannelSubMchId(), paySetting.getSubMchid())) { return markFailed(relation, "CONFIG_CHANGED", "支付宝平台账号或直付通二级商户标识已变化"); } AlipayTradeRoyaltyRelationBindModel model = new AlipayTradeRoyaltyRelationBindModel(); model.setOutRequestNo(relation.getOutRequestNo()); RoyaltyEntity receiver = new RoyaltyEntity(); receiver.setType(relation.getReceiverType()); - if ("loginName".equals(relation.getReceiverType())) { - receiver.setLoginName(relation.getReceiverAccount()); - } else { - receiver.setAccount(relation.getReceiverAccount()); - } + setAlipayReceiverIdentity(receiver, relation.getReceiverType(), relation.getReceiverAccount()); receiver.setName(relation.getReceiverName()); receiver.setMemo("平台默认分佣关系"); model.setReceiverList(List.of(receiver)); @@ -486,15 +1001,17 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService return markFailed(relation, errorCode, "支付宝分账关系配置或授权无效"); } - /** 支付宝关系绑定必须同时满足网关成功和业务 result_code=SUCCESS。 */ + /** + * 支付宝关系绑定必须同时满足网关成功和业务 result_code=SUCCESS。 + */ private boolean isAlipayBindSuccess(AlipayTradeRoyaltyRelationBindResponse response) { - return response != null && response.isSuccess() - && "SUCCESS".equalsIgnoreCase(StrUtil.trim(response.getResultCode())); + return response != null && response.isSuccess() && "SUCCESS".equalsIgnoreCase(StrUtil.trim(response.getResultCode())); } - /** 查询支付宝关系绑定结果。 */ - private AlipayTradeRoyaltyRelationBatchqueryResponse queryAlipay(AppPlusPayConfig.AliPayConfig config, - String outRequestNo, String appAuthToken) throws Exception { + /** + * 查询支付宝关系绑定结果。 + */ + private AlipayTradeRoyaltyRelationBatchqueryResponse queryAlipay(AppPlusPayConfig.AliPayConfig config, String outRequestNo, String appAuthToken) throws Exception { AlipayTradeRoyaltyRelationBatchqueryModel model = new AlipayTradeRoyaltyRelationBatchqueryModel(); model.setOutRequestNo(outRequestNo); model.setPageNum(1L); @@ -507,9 +1024,10 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService return client(config).execute(request); } - /** 查询异常只表示当前无法核实,不得覆盖原绑定请求的故障语义。 */ - private AlipayTradeRoyaltyRelationBatchqueryResponse tryQueryAlipay(AppPlusPayConfig.AliPayConfig config, - String outRequestNo, String appAuthToken) { + /** + * 查询异常只表示当前无法核实,不得覆盖原绑定请求的故障语义。 + */ + private AlipayTradeRoyaltyRelationBatchqueryResponse tryQueryAlipay(AppPlusPayConfig.AliPayConfig config, String outRequestNo, String appAuthToken) { try { return queryAlipay(config, outRequestNo, appAuthToken); } catch (Exception exception) { @@ -517,7 +1035,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService } } - /** 校验查询结果确实包含当前关系接收方,不能只凭查询接口成功判定绑定成功。 */ + /** + * 校验查询结果确实包含当前关系接收方,不能只凭查询接口成功判定绑定成功。 + */ private boolean hasAlipayReceiver(AlipayTradeRoyaltyRelationBatchqueryResponse response, ThirdMchFzRelationEntity relation) { if (response == null || !response.isSuccess() @@ -526,14 +1046,42 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService return false; } return response.getReceiverList().stream().anyMatch(receiver -> { - String account = "loginName".equals(relation.getReceiverType()) - ? receiver.getLoginName() : receiver.getAccount(); + String account = getAlipayReceiverIdentity(receiver, relation.getReceiverType()); return relation.getReceiverType().equals(receiver.getType()) && relation.getReceiverAccount().equals(account); }); } - /** 微信绑定成功响应必须与本地关系请求完全对应,避免把错误接收方误记为已绑定。 */ + /** + * 按支付宝关系类型写入 SDK 对应字段,禁止把四种身份都错误塞入 account。 + */ + private void setAlipayReceiverIdentity(RoyaltyEntity receiver, String receiverType, String receiverAccount) { + switch (receiverType) { + case "userId", "cardAliasNo" -> receiver.setAccount(receiverAccount); + case "loginName" -> { + receiver.setAccount(receiverAccount); + receiver.setLoginName(receiverAccount); + } + case "openId" -> receiver.setAccountOpenId(receiverAccount); + default -> throw new IllegalArgumentException("支付宝分账接收方类型不受支持"); + } + } + + /** + * 从支付宝关系查询响应读取接收方身份,用于精确核对绑定对象。 + */ + private String getAlipayReceiverIdentity(RoyaltyEntity receiver, String receiverType) { + return switch (receiverType) { + case "userId", "cardAliasNo" -> receiver.getAccount(); + case "loginName" -> receiver.getLoginName(); + case "openId" -> receiver.getAccountOpenId(); + default -> null; + }; + } + + /** + * 微信绑定成功响应必须与本地关系请求完全对应,避免把错误接收方误记为已绑定。 + */ private boolean wechatResponseMatches(AddReceiverResponse response, ThirdMchFzRelationEntity relation) { return Objects.equals(response.getSubMchid(), relation.getChannelSubMchId()) && response.getType() != null @@ -543,28 +1091,19 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService && Objects.equals(response.getRelationType().name(), relation.getChannelRelationType()); } - /** Worker 执行时通过内部 Feign 重新读取完整支付宝配置,关系表不保存授权 token。 */ + /** + * Worker 执行时直接从关系事实恢复支付宝渠道参数,不再回读支付配置 JSON。 + */ private MchPaySettingEntity.AliPaySetting loadAliPaySetting(ThirdMchFzRelationEntity relation) { - R response = remoteMchService.getById(relation.getMchId()); - if (response == null || !response.isOk() || response.getData() == null) { - throw new RetryableRelationException("MCH_QUERY_FAILED", "读取商户支付宝支付配置失败"); - } - MchPaySettingVo setting = response.getData().getPaySettingList() == null ? null - : response.getData().getPaySettingList().stream() - .filter(item -> relation.getPaySettingId().equals(item.getId())) - .findFirst().orElse(null); - if (setting == null || !ALIPAY_TYPE.equals(setting.getPlatformType())) { - throw new IllegalStateException("支付宝支付配置已停用或删除"); - } - MchPaySettingEntity.AliPaySetting result = parseAliPay(setting); - if (!Objects.equals(StrUtil.nullToEmpty(relation.getAccountKey()), StrUtil.nullToEmpty(result.getAccountKey()))) { - throw new IllegalStateException("支付宝平台账号配置已变化"); - } + MchPaySettingEntity.AliPaySetting result = new MchPaySettingEntity.AliPaySetting(); + result.setSubMchid(relation.getChannelSubMchId()); + result.setAccountKey(relation.getAccountKey()); + result.setAppAuthToken(relation.getAppAuthToken()); return result; } /** - * 动态创建支付宝客户端,命名账号不存在时直接失败;测试实现可替换客户端以验证渠道响应边界。 + * 动态创建平台统一支付宝客户端;测试实现可替换客户端以验证渠道响应边界。 * * @param config 已解析的平台支付宝账号配置 * @return 使用指定平台账号创建的支付宝 SDK 客户端 @@ -585,27 +1124,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService return new DefaultAlipayClient(sdk); } - /** 解析微信支付配置。 */ - private MchPaySettingEntity.WechatPaySetting parseWechat(MchPaySettingVo setting) { - MchPaySettingEntity.WechatPaySetting result = StrUtil.isBlank(setting.getSettingJson()) ? null - : JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class); - if (result == null || StrUtil.isBlank(result.getSubMchid())) { - throw new IllegalStateException("微信子商户号未配置"); - } - return result; - } - - /** 解析支付宝直付通配置。 */ - private MchPaySettingEntity.AliPaySetting parseAliPay(MchPaySettingVo setting) { - MchPaySettingEntity.AliPaySetting result = StrUtil.isBlank(setting.getSettingJson()) ? null - : JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - if (result == null || StrUtil.isBlank(result.getSubMchid())) { - throw new IllegalStateException("支付宝直付通二级商户标识未配置"); - } - return result; - } - - /** 读取微信平台配置。 */ + /** + * 读取微信平台配置。 + */ private AppPlusPayConfig.WechatConfig requireWechatConfig() { AppPlusPayConfig.WechatConfig config = appPlusPayConfig.getWechatConfig(); if (config == null || StrUtil.hasBlank(config.getAppPlatformAppId(), config.getMerchantId())) { @@ -614,29 +1135,22 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService return config; } - /** 按 accountKey 读取支付宝平台配置。 */ - private AppPlusPayConfig.AliPayConfig resolveAliPayConfig(MchPaySettingVo setting) { - MchPaySettingEntity.AliPaySetting paySetting = parseAliPay(setting); - return resolveAliPayConfig(paySetting); - } - - /** 按 accountKey 读取支付宝平台配置。 */ - private AppPlusPayConfig.AliPayConfig resolveAliPayConfig(MchPaySettingEntity.AliPaySetting setting) { + /** + * 读取平台统一支付宝应用配置;分佣接收方及商户授权上下文必须从关系表读取。 + * + * @return 支付宝平台应用配置 + */ + private AppPlusPayConfig.AliPayConfig requireAliPayConfig() { AppPlusPayConfig.AliPayConfig base = appPlusPayConfig.getAliPayConfig(); if (base == null) { throw new IllegalStateException("支付宝平台配置不存在"); } - if (StrUtil.isBlank(setting == null ? null : setting.getAccountKey())) { - return base; - } - AppPlusPayConfig.AliPayConfig named = base.getAccounts() == null ? null : base.getAccounts().get(setting.getAccountKey()); - if (named == null) { - throw new IllegalStateException("支付宝平台账号不存在"); - } - return named; + return base; } - /** 保存绑定成功快照。 */ + /** + * 保存绑定成功快照。 + */ private boolean markBound(ThirdMchFzRelationEntity relation, String response, String externalStatus) { return relationMapper.update(null, new LambdaUpdateWrapper() .eq(ThirdMchFzRelationEntity::getId, relation.getId()) @@ -649,7 +1163,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService .set(ThirdMchFzRelationEntity::getBoundTime, LocalDateTime.now())) > 0; } - /** 保存确定性失败或可重试失败快照。 */ + /** + * 保存确定性失败或可重试失败快照。 + */ private boolean markFailed(ThirdMchFzRelationEntity relation, String code, String message) { relationMapper.update(null, new LambdaUpdateWrapper() .eq(ThirdMchFzRelationEntity::getId, relation.getId()) @@ -661,7 +1177,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService return false; } - /** 保存可重试错误但保持 BINDING,后续必须使用相同关系主键和请求号重试。 */ + /** + * 保存可重试错误但保持 BINDING,后续必须使用相同关系主键和请求号重试。 + */ private void markRetry(ThirdMchFzRelationEntity relation, String code, String message) { relationMapper.update(null, new LambdaUpdateWrapper() .eq(ThirdMchFzRelationEntity::getId, relation.getId()) @@ -671,7 +1189,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService .set(ThirdMchFzRelationEntity::getLastQueryTime, LocalDateTime.now())); } - /** 微信响应脱敏快照。 */ + /** + * 微信响应脱敏快照。 + */ private String wechatResponseSnapshot(AddReceiverResponse response) { return JSONUtil.createObj().set("success", true).set("subMchid", response.getSubMchid()) .set("receiverType", response.getType() == null ? null : response.getType().name()) @@ -679,7 +1199,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService .set("relationType", response.getRelationType() == null ? null : response.getRelationType().name()).toString(); } - /** 支付宝绑定响应脱敏快照。 */ + /** + * 支付宝绑定响应脱敏快照。 + */ private String alipayBindSnapshot(AlipayTradeRoyaltyRelationBindResponse response) { return JSONUtil.createObj().set("success", response != null && response.isSuccess()) .set("code", response == null ? null : response.getCode()) @@ -687,7 +1209,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService .set("resultCode", response == null ? null : response.getResultCode()).toString(); } - /** 支付宝查询响应脱敏快照。 */ + /** + * 支付宝查询响应脱敏快照。 + */ private String alipayQuerySnapshot(AlipayTradeRoyaltyRelationBatchqueryResponse response) { return JSONUtil.createObj().set("success", response != null && response.isSuccess()) .set("code", response == null ? null : response.getCode()) @@ -696,7 +1220,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService .set("receiverCount", response == null || response.getReceiverList() == null ? 0 : response.getReceiverList().size()).toString(); } - /** 脱敏账号。 */ + /** + * 脱敏账号。 + */ private String mask(String value) { if (StrUtil.isBlank(value) || value.length() <= 4) { return "****"; @@ -708,22 +1234,30 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService * 表示渠道结果不确定的异常,Worker 捕获后按指数退避保留原请求号重试。 */ private static class RetryableRelationException extends RuntimeException { - /** 任务尝试日志使用的脱敏错误码。 */ + /** + * 任务尝试日志使用的脱敏错误码。 + */ private final String code; - /** 创建无底层异常的可重试错误。 */ + /** + * 创建无底层异常的可重试错误。 + */ RetryableRelationException(String code, String message) { super(message); this.code = code; } - /** 创建保留底层异常类型但不向数据库写入敏感报文的可重试错误。 */ + /** + * 创建保留底层异常类型但不向数据库写入敏感报文的可重试错误。 + */ RetryableRelationException(String code, String message, Throwable cause) { super(message, cause); this.code = code; } - /** 获取稳定错误码。 */ + /** + * 获取稳定错误码。 + */ String getCode() { return code; } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImpl.java index f06f0f31..48ba69e5 100755 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImpl.java @@ -18,13 +18,14 @@ import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayOrderDto; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayOrderMapper; import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskMapper; import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService; +import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayOrderVo; -import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import lombok.RequiredArgsConstructor; @@ -66,6 +67,10 @@ public class ThirdPayOrderServiceImpl extends ServiceImpl service.createPay(context, paySetting, diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java index 184070b0..f19a25e4 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java @@ -30,6 +30,7 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationStatusEnum; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; @@ -116,18 +117,15 @@ class ThirdAppPlusAliPayServiceImplTest { aliPayConfig.setBaseCallbackUrl("https://pay.example.com"); aliPayConfig.setPayCallbackUrl("/third-app-pay/app/pay-callback"); aliPayConfig.setRefundCallbackUrl("/third-app-pay/app/refund-callback"); - aliPayConfig.setRoyaltyTransIn("2088000000000001"); - aliPayConfig.setRoyaltyTransInType("userId"); - aliPayConfig.setRoyaltyTransInName("资安信平台"); appPlusPayConfig.setAliPayConfig(aliPayConfig); MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting(); aliPaySetting.setAppAuthToken("merchant-auth-token"); aliPaySetting.setSubMchid("smid-1001"); paySetting = new MchPaySettingVo(); + paySetting.setId(102L); paySetting.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType()); paySetting.setEnable(YesNoEnum.YES.getCode()); - paySetting.setFzRatio(10D); paySetting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); payDto = new ThirdAppPlusPayDto(); @@ -198,6 +196,21 @@ class ThirdAppPlusAliPayServiceImplTest { Assertions.assertFalse(requestCaptor.getValue().getTextParams().containsKey("app_auth_token")); } + /** 非当前默认关系即使已经绑定也不能用于创建新支付,避免内部调用伪造关系主键。 */ + @Test + void appPayShouldRejectBoundButNonDefaultRelation() throws Exception { + ThirdMchFzRelationEntity relation = boundAlipayRelation(); + relation.setIsDefault(YesNoEnum.NO.getCode()); + when(mchFzRelationService.getById(payDto.getFzRelationId())).thenReturn(relation); + + IllegalStateException exception = Assertions.assertThrows(IllegalStateException.class, + () -> service.appPay(payDto, paySetting)); + + Assertions.assertTrue(exception.getMessage().contains("默认且已绑定")); + verify(alipayClient, never()).sdkExecute(any(AlipayTradeAppPayRequest.class)); + verify(thirdPayOrderService, never()).save(any(ThirdPayOrderEntity.class)); + } + /** * 验证支付宝明确拒绝商户授权时结束直付通分账,不会降级调用普通平台抽佣接口。 */ @@ -340,6 +353,20 @@ class ThirdAppPlusAliPayServiceImplTest { verify(thirdFzOrderService).save(result); } + /** 低比例向下取整为零时,支付宝分账事实必须按渠道下限保存为一分钱。 */ + @Test + void ensureDirectPayFzOrderShouldUseOneFenMinimumAmount() { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + ThirdMchFzRelationEntity relation = boundAlipayRelation(); + relation.setFzRatio(0.001D); + when(mchFzRelationService.getById(payOrder.getFzRelationId())).thenReturn(relation); + + ThirdFzOrderEntity result = service.ensureDirectPayFzOrder(payOrder); + + Assertions.assertEquals(1, result.getPrice()); + verify(thirdFzOrderService).save(result); + } + /** * 验证已完成平台抽佣的支付宝订单退款携带按退款金额折算的 refund_royalty_parameters。 */ @@ -629,13 +656,17 @@ class ThirdAppPlusAliPayServiceImplTest { ThirdMchFzRelationEntity relation = new ThirdMchFzRelationEntity(); relation.setId(payDto.getFzRelationId()); relation.setMchId(payDto.getMchId()); + relation.setPaySettingId(paySetting.getId()); relation.setPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType()); relation.setChannelAppId(appPlusPayConfig.getAliPayConfig().getAppId()); relation.setChannelSubMchId("smid-1001"); relation.setReceiverType("userId"); relation.setReceiverAccount("2088000000000001"); relation.setReceiverName("资安信平台"); + relation.setFzRatio(10D); relation.setBoundTime(LocalDateTime.now().minusDays(1)); + relation.setStatus(ThirdMchFzRelationStatusEnum.BOUND.getCode()); + relation.setIsDefault(YesNoEnum.YES.getCode()); relation.setDelFlag("0"); return relation; } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java index b75afc0c..8e70c26c 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java @@ -657,7 +657,6 @@ class ThirdAppPlusServiceImplOrderCodePayTest { MchPaySettingVo setting = new MchPaySettingVo(); setting.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType()); setting.setEnable(YesNoEnum.YES.getCode()); - setting.setFzRatio(10D); setting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); MchManageVo result = new MchManageVo(); result.setId(88L); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java index 0187cc43..87a1dccb 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java @@ -96,7 +96,6 @@ class ThirdAppPlusWechatFzServiceImplTest { MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); wechatPaySetting.setSubMchid("wx-sub-1001"); MchPaySettingEntity setting = new MchPaySettingEntity(); - setting.setFzRatio(10D); setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); payOrder = new ThirdPayOrderEntity(); payOrder.setId(1L); @@ -131,6 +130,25 @@ class ThirdAppPlusWechatFzServiceImplTest { Assertions.assertEquals("1900000002", requestCaptor.getValue().getReceivers().get(0).getAccount()); } + /** 低比例向下取整为零时,微信分账请求必须按渠道下限提交一分钱。 */ + @Test + void createShouldUseOneFenMinimumAmount() { + payOrder.setPrice(11); + ThirdMchFzRelationEntity relation = boundWechatRelation(); + relation.setFzRatio(0.001D); + when(appPlusPayConfig.getWechatConfig()).thenReturn(wechatConfig()); + when(mchFzRelationService.getById(payOrder.getFzRelationId())).thenReturn(relation); + OrdersEntity response = new OrdersEntity(); + response.setState(OrderStatus.PROCESSING); + when(profitsharingService.createOrder(any(CreateOrderRequest.class))).thenReturn(response); + + service.create(payOrder); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(CreateOrderRequest.class); + verify(profitsharingService).createOrder(requestCaptor.capture()); + Assertions.assertEquals(1L, requestCaptor.getValue().getReceivers().get(0).getAmount()); + } + /** * 验证固定关系缺失时微信分账拒绝调用渠道,不会回退到当前服务商商户号。 */ @@ -273,6 +291,7 @@ class ThirdAppPlusWechatFzServiceImplTest { relation.setReceiverType("MERCHANT_ID"); relation.setReceiverAccount("1900000002"); relation.setReceiverName("平台分佣接收方"); + relation.setFzRatio(10D); relation.setBoundTime(LocalDateTime.now().minusDays(1)); relation.setDelFlag("0"); return relation; diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java index 59ae18b4..9a6d0b26 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java @@ -11,6 +11,7 @@ import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService; +import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; import com.bsd.pjcloud.thirdparty.service.impl.appplus.fz.lifecycle.ThirdAppPlusWechatFzLifecycle; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; @@ -67,6 +68,10 @@ class ThirdAppPlusWechatServiceImplPayCallbackTest { @Mock private ThirdAppPlusWechatFzLifecycle appPlusWechatFzLifecycle; + /** 分账关系服务 Mock,仅补齐微信支付服务的安全前置依赖。 */ + @Mock + private ThirdMchFzRelationService mchFzRelationService; + /** * 聚合支付会话服务 Mock,用于严格区分旧 Native 和新 JSAPI 子订单回调。 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java index 379dd4bb..baffd061 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java @@ -8,8 +8,10 @@ import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto; import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdMiniWechatRefundDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService; import com.bsd.pjcloud.thirdparty.service.impl.appplus.fz.lifecycle.ThirdAppPlusWechatFzLifecycle; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.wechat.ThirdMiniWechatRefundQueryVo; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; @@ -76,6 +78,10 @@ class ThirdAppPlusWechatServiceImplRefundTest { @Mock private ThirdAppPlusWechatFzLifecycle appPlusWechatFzLifecycle; + /** 分账关系服务 Mock,用于验证关系表中的非法比例会在微信预下单前被拒绝。 */ + @Mock + private ThirdMchFzRelationService mchFzRelationService; + /** 被测微信 App 支付服务。 */ @InjectMocks private ThirdAppPlusWechatServiceImpl service; @@ -145,19 +151,21 @@ class ThirdAppPlusWechatServiceImplRefundTest { } /** - * 验证非法微信分账比例在创建渠道支付前被拒绝,不会留下无法完成抽佣的已支付交易。 + * 验证关系服务判定抽佣比例非法时在渠道支付前拒绝,不会留下无法完成抽佣的已支付交易。 */ @Test void appPayShouldRejectInvalidProfitSharingRatioBeforeCreatingOrder() { MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); wechatPaySetting.setSubMchid("wx-sub-1001"); MchPaySettingVo setting = new MchPaySettingVo(); - setting.setFzRatio(101D); setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); ThirdAppPlusPayDto dto = new ThirdAppPlusPayDto(); dto.setAmount(1000); dto.setOutTradeNo("pay-invalid-ratio"); dto.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY); + dto.setFzRelationId(9001L); + when(mchFzRelationService.requirePaymentRelation(9001L, setting)) + .thenThrow(new IllegalStateException("微信平台抽佣比例必须大于0且不超过100%")); RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> service.appPay(dto, setting)); @@ -167,6 +175,38 @@ class ThirdAppPlusWechatServiceImplRefundTest { verify(thirdPayOrderService, org.mockito.Mockito.never()).save(any(ThirdPayOrderEntity.class)); } + /** 低比例计算结果不足一分钱时,微信预下单校验应采用一分钱下限而不是拒绝支付。 */ + @Test + void appPayShouldAcceptRatioWhoseCalculatedAmountIsBelowOneFen() { + AppPlusPayConfig.WechatConfig config = new AppPlusPayConfig.WechatConfig(); + config.setAppPlatformAppId("wx-app-id"); + config.setMerchantId("wx-service-mch"); + config.setBaseCallbackUrl("https://pay.example.com"); + config.setPayCallbackUrl("/pay-callback"); + when(appPlusPayConfig.getWechatConfig()).thenReturn(config); + PrepayWithRequestPaymentResponse response = new PrepayWithRequestPaymentResponse(); + response.setPrepayId("prepay-id"); + when(appServiceExtension.prepayWithRequestPayment(any(PrepayRequest.class), any())).thenReturn(response); + MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); + wechatPaySetting.setSubMchid("wx-sub-1001"); + MchPaySettingVo setting = new MchPaySettingVo(); + setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); + ThirdAppPlusPayDto dto = new ThirdAppPlusPayDto(); + dto.setAmount(11); + dto.setOutTradeNo("pay-minimum-fz-amount"); + dto.setBizId("order-minimum-fz-amount"); + dto.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY); + dto.setMchId(88L); + dto.setFzRelationId(9001L); + ThirdMchFzRelationEntity relation = new ThirdMchFzRelationEntity(); + relation.setFzRatio(0.001D); + when(mchFzRelationService.requirePaymentRelation(9001L, setting)).thenReturn(relation); + + service.appPay(dto, setting); + + verify(appServiceExtension).prepayWithRequestPayment(any(PrepayRequest.class), any()); + } + /** * 验证微信 App 预下单把本地保存的十五分钟截止时间同步传给渠道,避免本地和微信订单生命周期分叉。 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java index 6a02dc66..50df7458 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java @@ -2,6 +2,8 @@ package com.bsd.pjcloud.thirdparty.service.impl.pay; import cn.hutool.json.JSONUtil; import com.alipay.api.AlipayClient; +import com.alipay.api.domain.AlipayTradeRoyaltyRelationBindModel; +import com.alipay.api.domain.RoyaltyEntity; import com.alipay.api.request.AlipayTradeRoyaltyRelationBatchqueryRequest; import com.alipay.api.request.AlipayTradeRoyaltyRelationBindRequest; import com.alipay.api.response.AlipayTradeRoyaltyRelationBatchqueryResponse; @@ -10,9 +12,12 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationStatusEnum; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdMchFzRelationSaveDto; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdMchFzRelationMapper; import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer; @@ -25,6 +30,7 @@ import com.wechat.pay.java.service.profitsharing.model.AddReceiverResponse; import com.wechat.pay.java.service.profitsharing.model.ReceiverRelationType; import com.wechat.pay.java.service.profitsharing.model.ReceiverType; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -32,14 +38,11 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.apache.ibatis.builder.MapperBuilderAssistant; -import org.springframework.dao.DuplicateKeyException; import java.time.LocalDateTime; import java.util.List; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.clearInvocations; -import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -97,108 +100,59 @@ class ThirdMchFzRelationServiceImplTest { alipay.setAppId("2026000000000001"); alipay.setAppPrivateKey("test-private-key"); alipay.setAlipayPublicKey("test-public-key"); - alipay.setRoyaltyTransInType("userId"); - alipay.setRoyaltyTransIn("2088000000000001"); - alipay.setRoyaltyTransInName("平台支付宝账号"); config.setAliPayConfig(alipay); service = new TestableThirdMchFzRelationServiceImpl(relationMapper, taskEnqueuer, profitsharingService, config, remoteMchService, alipayClient); + TenantContextHolder.setTenantId(TENANT_ID); } - /** 微信和支付宝启用抽佣后分别生成一条默认关系,且请求快照不保存授权 Token。 */ + /** 清理测试线程租户,防止同进程其他用例继承本类上下文。 */ + @AfterEach + void tearDown() { + TenantContextHolder.clear(); + } + + /** 支付配置同步只处理关系生命周期,不再从支付配置 JSON 自动创建渠道关系。 */ @Test - void syncDefaultRelationsShouldCreateWechatAndAlipayRelations() { - when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(null); + void syncDefaultRelationsShouldNotCreateRelationsFromSettingJson() { MchPaySettingVo wechat = wechatSetting(101L, 3D); MchPaySettingVo alipay = alipaySetting(102L, 5D, "sensitive-auth-token"); service.syncDefaultRelations(merchant(List.of(wechat, alipay))); - ArgumentCaptor captor = ArgumentCaptor.forClass(ThirdMchFzRelationEntity.class); - verify(relationMapper, times(2)).insert(captor.capture()); - List relations = captor.getAllValues(); - ThirdMchFzRelationEntity wechatRelation = relations.stream() - .filter(item -> AppPlusPlatformPayType.WECHAT.getType().equals(item.getPlatformType())) - .findFirst().orElseThrow(); - ThirdMchFzRelationEntity alipayRelation = relations.stream() - .filter(item -> AppPlusPlatformPayType.ALI_PAY.getType().equals(item.getPlatformType())) - .findFirst().orElseThrow(); - Assertions.assertAll( - () -> Assertions.assertEquals("wx-sub-1001", wechatRelation.getChannelSubMchId()), - () -> Assertions.assertEquals("1900000002", wechatRelation.getReceiverAccount()), - () -> Assertions.assertEquals(ReceiverType.MERCHANT_ID.name(), wechatRelation.getReceiverType()), - () -> Assertions.assertEquals(ReceiverRelationType.SERVICE_PROVIDER.name(), wechatRelation.getChannelRelationType()), - () -> Assertions.assertEquals("smid-1001", alipayRelation.getChannelSubMchId()), - () -> Assertions.assertEquals("2088000000000001", alipayRelation.getReceiverAccount()), - () -> Assertions.assertEquals("userId", alipayRelation.getReceiverType()), - () -> Assertions.assertFalse(alipayRelation.getBindReq().contains("sensitive-auth-token")), - () -> Assertions.assertTrue(alipayRelation.getBindReq().contains("appAuthTokenConfigured"))); - } - - /** 相同关系身份重复同步时复用既有记录,不重复插入渠道关系事实。 */ - @Test - void syncDefaultRelationsShouldReuseExistingIdentity() { - MchPaySettingVo setting = wechatSetting(101L, 3D); - when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(null); - service.syncDefaultRelations(merchant(List.of(setting))); - ArgumentCaptor captor = ArgumentCaptor.forClass(ThirdMchFzRelationEntity.class); - verify(relationMapper).insert(captor.capture()); - ThirdMchFzRelationEntity existing = captor.getValue(); - existing.setStatus(ThirdMchFzRelationStatusEnum.BOUND.getCode()); - existing.setBoundTime(LocalDateTime.now()); - - clearInvocations(relationMapper, taskEnqueuer); - when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(existing); - service.syncDefaultRelations(merchant(List.of(setting))); - verify(relationMapper, never()).insert(any(ThirdMchFzRelationEntity.class)); verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); } - /** 并发插入命中唯一键时读取胜出记录继续处理,不把正常竞争暴露为配置保存失败。 */ - @Test - void syncDefaultRelationsShouldReuseWinnerAfterDuplicateKey() { - ThirdMchFzRelationEntity winner = pendingWechatRelation(7001L); - when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(null, winner); - doThrow(new DuplicateKeyException("duplicate relation")).when(relationMapper) - .insert(any(ThirdMchFzRelationEntity.class)); - - service.syncDefaultRelations(merchant(List.of(wechatSetting(101L, 3D)))); - - verify(taskEnqueuer).enqueueRelationBind(org.mockito.ArgumentMatchers.eq(TENANT_ID), - org.mockito.ArgumentMatchers.eq(7001L), any(), org.mockito.ArgumentMatchers.eq(150)); - } - - /** 关系尚未绑定时支付入口必须拒绝,并只确保稳定任务存在而不重置既有退避。 */ + /** 正比例默认关系尚未绑定时支付入口必须拒绝,并只确保稳定任务存在而不重置既有退避。 */ @Test void requireBoundShouldRejectAndEnsureTaskWithoutRearm() { - when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(null).thenReturn(null); + ThirdMchFzRelationEntity pending = pendingWechatRelation(7005L); + pending.setIsDefault(YesNoEnum.YES.getCode()); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(pending); IllegalStateException exception = Assertions.assertThrows(IllegalStateException.class, () -> service.requireBound(wechatSetting(101L, 3D))); Assertions.assertTrue(exception.getMessage().contains("尚未绑定")); - ArgumentCaptor relationCaptor = ArgumentCaptor.forClass(ThirdMchFzRelationEntity.class); - verify(relationMapper).insert(relationCaptor.capture()); verify(taskEnqueuer).ensureRelationBind(org.mockito.ArgumentMatchers.eq(TENANT_ID), - org.mockito.ArgumentMatchers.eq(relationCaptor.getValue().getId()), any(), org.mockito.ArgumentMatchers.eq(150)); + org.mockito.ArgumentMatchers.eq(pending.getId()), any(), org.mockito.ArgumentMatchers.eq(150)); + verify(relationMapper, never()).insert(any(ThirdMchFzRelationEntity.class)); verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); } - /** 跨服务保存回滚留下的历史已绑定关系应按当前配置恢复,避免支付永久被拒绝。 */ + /** 零比例关系不满足支付前置条件,不能绕过关系表抽佣规则继续下单。 */ @Test - void requireBoundShouldRestoreMatchingHistoricalBoundRelation() { - ThirdMchFzRelationEntity historical = pendingWechatRelation(7004L); - historical.setStatus(ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode()); - historical.setIsDefault(YesNoEnum.NO.getCode()); - historical.setBoundTime(LocalDateTime.now()); - when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(null, historical); + void requireBoundShouldRejectWhenDefaultRelationRatioIsZero() { + ThirdMchFzRelationEntity disabled = pendingWechatRelation(7004L); + disabled.setFzRatio(0D); + disabled.setIsDefault(YesNoEnum.YES.getCode()); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(disabled); - ThirdMchFzRelationEntity result = service.requireBound(wechatSetting(101L, 3D)); + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, + () -> service.requireBound(wechatSetting(101L, 3D))); - Assertions.assertSame(historical, result); - Assertions.assertEquals(ThirdMchFzRelationStatusEnum.BOUND.getCode(), result.getStatus()); - Assertions.assertEquals(YesNoEnum.YES.getCode(), result.getIsDefault()); + Assertions.assertTrue(exception.getMessage().contains("大于0")); verify(taskEnqueuer, never()).ensureRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); } @@ -230,9 +184,6 @@ class ThirdMchFzRelationServiceImplTest { binding.setStatus(ThirdMchFzRelationStatusEnum.BINDING.getCode()); when(relationMapper.selectById(7003L)).thenReturn(pending, binding); when(relationMapper.update(any(), any())).thenReturn(1); - when(remoteMchService.getById(MCH_ID)).thenReturn(com.bsd.pjcloud.common.core.util.R.ok( - merchant(List.of(alipaySetting(102L, 5D, "merchant-auth-token"))))); - AlipayTradeRoyaltyRelationBindResponse bindResponse = new AlipayTradeRoyaltyRelationBindResponse(); bindResponse.setCode("10000"); bindResponse.setResultCode("FAIL"); @@ -246,14 +197,145 @@ class ThirdMchFzRelationServiceImplTest { verify(relationMapper, times(2)).update(any(), any()); } - /** 负数或超过百分之百的抽佣配置必须在关系物化前拒绝。 */ + /** 支付宝 openId 关系必须写入 account_open_id,不能错误写入 account 或 login_name。 */ @Test - void syncDefaultRelationsShouldRejectInvalidRatio() { - Assertions.assertThrows(IllegalArgumentException.class, - () -> service.syncDefaultRelations(merchant(List.of(wechatSetting(101L, -1D))))); - Assertions.assertThrows(IllegalArgumentException.class, - () -> service.syncDefaultRelations(merchant(List.of(alipaySetting(102L, 101D, null))))); + void bindAlipayShouldMapOpenIdToAccountOpenId() throws Exception { + RoyaltyEntity receiver = bindAlipayAndCaptureReceiver("openId", "alipay-open-id-1001"); + + Assertions.assertAll( + () -> Assertions.assertEquals("openId", receiver.getType()), + () -> Assertions.assertEquals("alipay-open-id-1001", receiver.getAccountOpenId()), + () -> Assertions.assertNull(receiver.getAccount()), + () -> Assertions.assertNull(receiver.getLoginName())); + } + + /** 支付宝卡编号关系使用 account 承载 cardAliasNo,类型字段明确区分于 userId。 */ + @Test + void bindAlipayShouldMapCardAliasNoToAccount() throws Exception { + RoyaltyEntity receiver = bindAlipayAndCaptureReceiver("cardAliasNo", "card-alias-1001"); + + Assertions.assertAll( + () -> Assertions.assertEquals("cardAliasNo", receiver.getType()), + () -> Assertions.assertEquals("card-alias-1001", receiver.getAccount()), + () -> Assertions.assertNull(receiver.getAccountOpenId()), + () -> Assertions.assertNull(receiver.getLoginName())); + } + + /** 修改接收方账号必须创建新关系版本,旧关系仅退出默认候选并保留给历史订单。 */ + @Test + void saveForManageShouldVersionRelationWhenReceiverAccountChanges() { + MchPaySettingVo setting = wechatSetting(101L, 3D); + ThirdMchFzRelationEntity existing = boundWechatRelation(7006L); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(existing); + when(relationMapper.update(any(), any())).thenReturn(1); + ThirdMchFzRelationSaveDto dto = manageEditDto(existing); + dto.setReceiverAccount("1900000099"); + + var result = service.saveForManage(dto); + + ArgumentCaptor relationCaptor = ArgumentCaptor.forClass(ThirdMchFzRelationEntity.class); + verify(relationMapper).insert(relationCaptor.capture()); + ThirdMchFzRelationEntity replacement = relationCaptor.getValue(); + Assertions.assertAll( + () -> Assertions.assertNotEquals(existing.getId(), replacement.getId()), + () -> Assertions.assertEquals(replacement.getId(), result.getId()), + () -> Assertions.assertEquals("1900000099", replacement.getReceiverAccount()), + () -> Assertions.assertEquals(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode(), replacement.getStatus()), + () -> Assertions.assertEquals(ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode(), existing.getStatus()), + () -> Assertions.assertEquals(YesNoEnum.NO.getCode(), existing.getIsDefault())); + verify(taskEnqueuer).enqueueRelationBind(org.mockito.ArgumentMatchers.eq(TENANT_ID), + org.mockito.ArgumentMatchers.eq(replacement.getId()), any(), org.mockito.ArgumentMatchers.eq(150)); + } + + /** 修改接收方名称不改变历史接收账号,但必须清空旧结果并唤醒原关系绑定任务。 */ + @Test + void saveForManageShouldRebindCurrentRelationWhenReceiverNameChanges() { + MchPaySettingVo setting = wechatSetting(101L, 3D); + ThirdMchFzRelationEntity existing = boundWechatRelation(7007L); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(existing); + when(relationMapper.selectCount(any(Wrapper.class))).thenReturn(1L); + when(relationMapper.update(any(), any())).thenReturn(1); + ThirdMchFzRelationSaveDto dto = manageEditDto(existing); + dto.setReceiverName("新的平台名称"); + + var result = service.saveForManage(dto); + + Assertions.assertAll( + () -> Assertions.assertEquals(existing.getId(), result.getId()), + () -> Assertions.assertEquals("新的平台名称", result.getReceiverName()), + () -> Assertions.assertEquals(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode(), result.getStatus()), + () -> Assertions.assertNull(result.getLastExternalStatus()), + () -> Assertions.assertNull(result.getBoundTime())); verify(relationMapper, never()).insert(any(ThirdMchFzRelationEntity.class)); + verify(taskEnqueuer).enqueueRelationBind(org.mockito.ArgumentMatchers.eq(TENANT_ID), + org.mockito.ArgumentMatchers.eq(existing.getId()), any(), org.mockito.ArgumentMatchers.eq(150)); + } + + /** 只修改抽佣比例属于本地结算规则,不需要重复调用渠道关系绑定接口。 */ + @Test + void saveForManageShouldNotRebindWhenOnlyRatioChanges() { + MchPaySettingVo setting = wechatSetting(101L, 3D); + ThirdMchFzRelationEntity existing = boundWechatRelation(7008L); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(existing); + when(relationMapper.selectCount(any(Wrapper.class))).thenReturn(1L); + when(relationMapper.update(any(), any())).thenReturn(1); + ThirdMchFzRelationSaveDto dto = manageEditDto(existing); + dto.setFzRatio(2.125D); + + var result = service.saveForManage(dto); + + Assertions.assertAll( + () -> Assertions.assertEquals(2.125D, result.getFzRatio()), + () -> Assertions.assertEquals(ThirdMchFzRelationStatusEnum.BOUND.getCode(), result.getStatus())); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 接收方类型变化时不能沿用旧类型的隐藏账号,管理端必须提交完整新标识。 */ + @Test + void saveForManageShouldRejectTypeChangeWithoutFullAccount() { + MchPaySettingVo setting = wechatSetting(101L, 3D); + ThirdMchFzRelationEntity existing = boundWechatRelation(7009L); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(existing); + ThirdMchFzRelationSaveDto dto = manageEditDto(existing); + dto.setReceiverType("PERSONAL_OPENID"); + + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, + () -> service.saveForManage(dto)); + + Assertions.assertTrue(exception.getMessage().contains("完整的新接收方标识")); + verify(relationMapper, never()).insert(any(ThirdMchFzRelationEntity.class)); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 新建支付宝关系的绑定幂等号必须满足支付宝32字符限制,并保留可审计的平台前缀。 */ + @Test + void saveForManageShouldGenerateShortAlipayOutRequestNo() { + MchPaySettingVo setting = alipaySetting(102L, 5D, "merchant-auth-token"); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.selectCount(any(Wrapper.class))).thenReturn(0L); + ThirdMchFzRelationSaveDto dto = new ThirdMchFzRelationSaveDto(); + dto.setMchId(MCH_ID); + dto.setPaySettingId(setting.getId()); + dto.setChannelSubMchId("smid-1001"); + dto.setReceiverType("userId"); + dto.setReceiverAccount("2088000000000001"); + dto.setReceiverName("平台支付宝账号"); + dto.setFzRatio(5D); + + ArgumentCaptor relationCaptor = ArgumentCaptor.forClass(ThirdMchFzRelationEntity.class); + when(relationMapper.insert(relationCaptor.capture())).thenReturn(1); + + service.saveForManage(dto); + + String outRequestNo = relationCaptor.getValue().getOutRequestNo(); + Assertions.assertAll( + () -> Assertions.assertTrue(outRequestNo.startsWith("ar_")), + () -> Assertions.assertTrue(outRequestNo.length() <= 32), + () -> Assertions.assertTrue(outRequestNo.matches("[a-z0-9_]+"))); } /** 构造商户关系同步快照。 */ @@ -265,14 +347,14 @@ class ThirdMchFzRelationServiceImplTest { return merchant; } - /** 构造启用抽佣的微信子商户支付配置。 */ + /** 构造微信子商户支付配置;ratio 参数仅保留测试调用兼容,比例不再属于支付配置。 */ private MchPaySettingVo wechatSetting(Long id, Double ratio) { MchPaySettingEntity.WechatPaySetting detail = new MchPaySettingEntity.WechatPaySetting(); detail.setSubMchid("wx-sub-1001"); return setting(id, AppPlusPlatformPayType.WECHAT.getType(), ratio, JSONUtil.toJsonStr(detail)); } - /** 构造启用抽佣的支付宝直付通支付配置。 */ + /** 构造支付宝直付通支付配置;ratio 参数仅保留测试调用兼容,比例不再属于支付配置。 */ private MchPaySettingVo alipaySetting(Long id, Double ratio, String appAuthToken) { MchPaySettingEntity.AliPaySetting detail = new MchPaySettingEntity.AliPaySetting(); detail.setSubMchid("smid-1001"); @@ -288,7 +370,6 @@ class ThirdMchFzRelationServiceImplTest { setting.setMchId(MCH_ID); setting.setPlatformType(platformType); setting.setEnable(YesNoEnum.YES.getCode()); - setting.setFzRatio(ratio); setting.setSettingJson(settingJson); return setting; } @@ -306,12 +387,42 @@ class ThirdMchFzRelationServiceImplTest { relation.setReceiverType(ReceiverType.MERCHANT_ID.name()); relation.setReceiverAccount("1900000002"); relation.setReceiverName("平台服务商"); + relation.setFzRatio(3D); relation.setChannelRelationType(ReceiverRelationType.SERVICE_PROVIDER.name()); relation.setStatus(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode()); relation.setDelFlag("0"); return relation; } + /** 构造已完成渠道绑定且正在承接新支付的微信关系。 */ + private ThirdMchFzRelationEntity boundWechatRelation(Long id) { + ThirdMchFzRelationEntity relation = pendingWechatRelation(id); + relation.setOutRequestNo("wxrel_" + id); + relation.setStatus(ThirdMchFzRelationStatusEnum.BOUND.getCode()); + relation.setIsDefault(YesNoEnum.YES.getCode()); + relation.setBindRes("{\"success\":true}"); + relation.setLastExternalStatus("SUCCESS"); + relation.setLastQueryTime(LocalDateTime.now().minusMinutes(1)); + relation.setBoundTime(LocalDateTime.now().minusMinutes(1)); + return relation; + } + + /** 构造编辑关系的管理端参数;接收方账号故意留空以模拟脱敏列表的真实提交方式。 */ + private ThirdMchFzRelationSaveDto manageEditDto(ThirdMchFzRelationEntity relation) { + ThirdMchFzRelationSaveDto dto = new ThirdMchFzRelationSaveDto(); + dto.setId(relation.getId()); + dto.setMchId(relation.getMchId()); + dto.setPaySettingId(relation.getPaySettingId()); + dto.setChannelSubMchId(relation.getChannelSubMchId()); + dto.setReceiverType(relation.getReceiverType()); + dto.setReceiverAccount(""); + dto.setReceiverName(relation.getReceiverName()); + dto.setFzRatio(relation.getFzRatio()); + dto.setChannelRelationType(relation.getChannelRelationType()); + dto.setIsDefault(YesNoEnum.NO.getCode()); + return dto; + } + /** 构造等待绑定的支付宝直付通平台分佣关系事实。 */ private ThirdMchFzRelationEntity pendingAlipayRelation(Long id) { ThirdMchFzRelationEntity relation = new ThirdMchFzRelationEntity(); @@ -325,12 +436,38 @@ class ThirdMchFzRelationServiceImplTest { relation.setReceiverType("userId"); relation.setReceiverAccount("2088000000000001"); relation.setReceiverName("平台支付宝账号"); + relation.setFzRatio(5D); + relation.setAppAuthToken("merchant-auth-token"); relation.setOutRequestNo("alirel_7003"); relation.setStatus(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode()); relation.setDelFlag("0"); return relation; } + /** 执行一次支付宝绑定并返回 SDK 请求中的接收方模型。 */ + private RoyaltyEntity bindAlipayAndCaptureReceiver(String receiverType, String receiverAccount) throws Exception { + ThirdMchFzRelationEntity pending = pendingAlipayRelation(7003L); + pending.setReceiverType(receiverType); + pending.setReceiverAccount(receiverAccount); + ThirdMchFzRelationEntity binding = pendingAlipayRelation(7003L); + binding.setReceiverType(receiverType); + binding.setReceiverAccount(receiverAccount); + binding.setStatus(ThirdMchFzRelationStatusEnum.BINDING.getCode()); + when(relationMapper.selectById(7003L)).thenReturn(pending, binding); + when(relationMapper.update(any(), any())).thenReturn(1); + AlipayTradeRoyaltyRelationBindResponse response = new AlipayTradeRoyaltyRelationBindResponse(); + response.setCode("10000"); + response.setResultCode("SUCCESS"); + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(AlipayTradeRoyaltyRelationBindRequest.class); + when(alipayClient.execute(requestCaptor.capture())).thenReturn(response); + + Assertions.assertTrue(service.bind(7003L)); + AlipayTradeRoyaltyRelationBindModel model = + (AlipayTradeRoyaltyRelationBindModel) requestCaptor.getValue().getBizModel(); + return model.getReceiverList().get(0); + } + /** 固定返回 Mock 支付宝客户端的关系服务测试实现。 */ private static final class TestableThirdMchFzRelationServiceImpl extends ThirdMchFzRelationServiceImpl { diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java index d71761cf..509fe494 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java @@ -67,8 +67,7 @@ public interface RemoteUserService { * @return 含准确总数的轻量用户分页;调用方必须检查业务状态和空数据 */ @PostMapping("/user/basic-page") - R> getScopeBasicUserPage(@RequestParam("current") Long current, @RequestParam("size") Long size, - @RequestBody UserDTO userDTO); + R> getScopeBasicUserPage(@RequestParam("current") Long current, @RequestParam("size") Long size, @RequestBody UserDTO userDTO); /** * 按当前登录人的数据权限分页查询导出所需的完整用户主资料和身份资料。 diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java index 5c8dc258..ad4e16ee 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java @@ -97,10 +97,14 @@ public class SysUserController { private final SysUserService userService; - /** 用户 Excel 导入导出服务,统一承接用户模块的文件转换与导入编排。 */ + /** + * 用户 Excel 导入导出服务,统一承接用户模块的文件转换与导入编排。 + */ private final SysUserImportExportService userImportExportService; - /** 用户 Excel 导出服务,独立承接模板填充、身份资料组装和结果文件保存。 */ + /** + * 用户 Excel 导出服务,独立承接模板填充、身份资料组装和结果文件保存。 + */ private final SysUserExportService userExportService; private final RemoterVisitorService remoterVisitorService; @@ -110,7 +114,9 @@ public class SysUserController { private final SysRoleService sysRoleService; private final SysUserRoleService sysUserRoleService; - /** 学生身份服务,用于跨服务开卡导入按学校和学号精确定位学生。 */ + /** + * 学生身份服务,用于跨服务开卡导入按学校和学号精确定位学生。 + */ private final SysUserStudentService sysUserStudentService; private final SysMessageService sysMessageService; /* ************************************* start 回调 start ************************************* */ @@ -162,7 +168,7 @@ public class SysUserController { * 根据学校和学号精确查询学生用户,供钱包学生开卡导入校验使用。 * * @param schoolName 学生身份扩展信息中的学校名称 - * @param studentNo 学生身份扩展信息中的学号 + * @param studentNo 学生身份扩展信息中的学号 * @return 唯一学生用户;不存在时返回失败响应 */ @GetMapping("/get-student-by-school-and-no") @@ -336,14 +342,15 @@ public class SysUserController { * *

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

* - * @param page 分页参数 + * @param page 分页参数 * @param userDTO 用户关键字、部门树和指定用户筛选条件 * @return 包含用户总数、基础信息、部门和身份资料的轻量分页 */ @ApiCryptoAes @PostMapping("/basic-page") public R> getBasicUserPage(@ParameterObject Page page, @RequestBody UserDTO userDTO) { - return R.ok(userService.getBasicUsersPage(page, userDTO, DataScope.of())); + DataScope dataScope = DataScope.of(); + return R.ok(userService.getBasicUsersPage(page, userDTO, dataScope)); } /** @@ -351,7 +358,7 @@ public class SysUserController { * *

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

* - * @param page 分页参数 + * @param page 分页参数 * @param userDTO 用户关键字、部门树和指定用户筛选条件 * @return 包含用户总数和完整导出资料的分页 */ @@ -467,11 +474,11 @@ public class SysUserController { } /** - * 导入后台用户基础信息,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。 + * 导入后台用户基础信息,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。 * - * @param excelVOList Excel 中解析出的后台用户数据 + * @param excelVOList Excel 中解析出的后台用户数据 * @param bindingResult Excel 通用校验产生的错误信息 - * @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度 + * @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度 */ @PostMapping("/import") @HasPermission("sys_user_import") @@ -480,11 +487,11 @@ public class SysUserController { } /** - * 导入学生用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。 + * 导入学生用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。 * * @param studentUserExcelList Excel 中解析出的学生用户数据 - * @param bindingResult Excel 通用校验产生的错误信息 - * @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度 + * @param bindingResult Excel 通用校验产生的错误信息 + * @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度 */ @PostMapping("/import-student") @HasPermission("sys_student_user_export") @@ -494,12 +501,12 @@ public class SysUserController { } /** - * 导入学生家长用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。 + * 导入学生家长用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。 *

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

* * @param guardianUserExcelList Excel 中解析出的学生家长用户数据 - * @param bindingResult Excel 通用校验产生的错误信息 - * @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度 + * @param bindingResult Excel 通用校验产生的错误信息 + * @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度 */ @PostMapping("/import-guardian") @HasPermission("sys_guardian_user_export") @@ -509,12 +516,12 @@ public class SysUserController { } /** - * 导入老师用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。 + * 导入老师用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。 *

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

* * @param teacherUserExcelList Excel 中解析出的老师用户数据 - * @param bindingResult Excel 通用校验产生的错误信息 - * @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度 + * @param bindingResult Excel 通用校验产生的错误信息 + * @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度 */ @PostMapping("/import-teacher") @HasPermission("sys_teacher_user_export") @@ -524,12 +531,12 @@ public class SysUserController { } /** - * 导入用户及 Excel 中内嵌的人脸图片,接口同步校验并复制图片字节,校验通过后返回异步任务供调用端轮询。 + * 导入用户及 Excel 中内嵌的人脸图片,接口同步校验并复制图片字节,校验通过后返回异步任务供调用端轮询。 * - * @param file 原始 Excel 文件,用于读取内嵌的人脸图片 - * @param excelList Excel 中解析出的用户数据 + * @param file 原始 Excel 文件,用于读取内嵌的人脸图片 + * @param excelList Excel 中解析出的用户数据 * @param bindingResult Excel 通用校验产生的错误信息 - * @return 校验失败时包含校验或图片处理错误;校验通过时包含异步任务 ID 和初始进度 + * @return 校验失败时包含校验或图片处理错误;校验通过时包含异步任务 ID 和初始进度 */ @PostMapping("/import-face") @HasPermission("sys_user_import") diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java index 7996b942..5838784d 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java @@ -342,7 +342,8 @@ public class SysUserServiceImpl extends ServiceImpl impl */ @Override public IPage getUsersWithRolePage(Page page, UserDTO userDTO, DataScope dataScope) { - if (ObjectUtil.isNotEmpty(dataScope)) { + if (dataScope != null) { + // 用户查询只按所属部门做数据权限过滤,不能再用用户创建人限制可见范围。 dataScope.setScopeUserName(null); } IPage list = baseMapper.getUserVosPage(page, userDTO, dataScope); @@ -378,8 +379,8 @@ public class SysUserServiceImpl extends ServiceImpl impl */ @Override public IPage getBasicUsersPage(Page page, UserDTO userDTO, DataScope dataScope) { - if (ObjectUtil.isNotEmpty(dataScope)) { - // 用户查询只按所属部门做数据权限过滤,不能再用用户创建人限制可见范围。 + if (dataScope != null) { + // DataScope 继承 HashMap,不能使用集合非空判断;此查询只按所属部门过滤。 dataScope.setScopeUserName(null); } IPage list = baseMapper.getBasicUserPage(page, userDTO, dataScope); @@ -401,7 +402,7 @@ public class SysUserServiceImpl extends ServiceImpl impl */ @Override public IPage getExportUsersPage(Page page, UserDTO userDTO, DataScope dataScope) { - if (ObjectUtil.isNotEmpty(dataScope)) { + if (dataScope != null) { // 导出与列表使用相同的部门数据权限口径,不按创建人额外缩小用户范围。 dataScope.setScopeUserName(null); } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java index 7d87b4e0..13d7397a 100755 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java @@ -56,12 +56,6 @@ public class MchPaySettingEntity extends Model { @Schema(description = "支付json配置") private String settingJson; - /** - * 平台抽佣比例,单位为百分比,取值范围为大于0且不超过100。 - */ - @Schema(description = "平台抽佣比例,单位为百分比,取值范围为(0,100]") - private Double fzRatio; - /** * 创建人 */ @@ -108,7 +102,7 @@ public class MchPaySettingEntity extends Model { static public class WechatPaySetting { /** - * 子商户号 + * 订单创建时从分账关系复制的微信子商户号;不得写回 mch_pay_setting.setting_json。 */ private String subMchid; @@ -117,17 +111,17 @@ public class MchPaySettingEntity extends Model { @Data static public class AliPaySetting { /** - * 支付宝直付通二级商户标识,保存支付宝分配的 smid/ip_role_id,不是平台应用商户号。 + * 订单创建时从分账关系复制的支付宝 smid/ip_role_id;不得写回支付配置。 */ private String subMchid; /** - * 支付宝第三方代调用授权令牌;无需代调用授权的账号允许为空。 + * 订单创建时从分账关系复制的第三方代调用授权令牌;无需代调用授权时为空。 */ private String appAuthToken; /** - * 平台支付宝命名账号键,来源于现有支付配置 JSON;为空时使用平台默认账号。 + * 订单创建时从分账关系复制的平台支付宝命名账号键;为空时使用平台默认账号。 */ private String accountKey; } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java index 56298421..dcdbf679 100755 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java @@ -14,7 +14,6 @@ import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.security.service.PjcloudUser; -import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppPlusPayService; import com.bsd.pjcloud.wallet.api.dto.mch.MchPaySettingDto; import com.bsd.pjcloud.wallet.api.entity.mch.MchManageEntity; @@ -60,7 +59,7 @@ public class MchPaySettingServiceImpl extends ServiceImpl pageList(MchPaySettingDto dto, DataScope dataScope) { - return baseMapper.pageList(dto, dataScope); + return baseMapper.pageList(dto, dataScope).stream().map(this::sanitizeForClient).toList(); } /** @@ -79,8 +78,9 @@ public class MchPaySettingServiceImpl extends ServiceImpl滚动发布期间旧管理端仍可能提交这些键,钱包服务必须在持久化边界统一剔除, + * 防止支付配置 JSON 与关系事实形成两个相互漂移的配置源。

+ * + * @param setting 待持久化或返回客户端的支付配置 */ - private void preserveAliPayAuthToken(MchPaySettingDto dto) { - if (dto == null || dto.getId() == null || !AppPlusPlatformPayType.ALI_PAY.getType().equals(dto.getPlatformType()) - || StrUtil.isBlank(dto.getSettingJson())) { + private void removeRelationOwnedFields(MchPaySettingEntity setting) { + if (setting == null || StrUtil.isBlank(setting.getSettingJson())) { return; } - MchPaySettingEntity existing = getById(dto.getId()); - if (existing == null || StrUtil.isBlank(existing.getSettingJson())) { - return; + if (!JSONUtil.isTypeJSON(setting.getSettingJson())) { + throw new RuntimeException("支付配置JSON无效"); } try { - MchPaySettingEntity.AliPaySetting incoming = JSONUtil.toBean( - dto.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - MchPaySettingEntity.AliPaySetting stored = JSONUtil.toBean( - existing.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - if (incoming != null && stored != null && StrUtil.isBlank(incoming.getAppAuthToken()) - && StrUtil.isNotBlank(stored.getAppAuthToken())) { - incoming.setAppAuthToken(stored.getAppAuthToken()); - dto.setSettingJson(JSONUtil.toJsonStr(incoming)); - } + var settingJson = JSONUtil.parseObj(setting.getSettingJson()); + settingJson.remove("subMchid"); + settingJson.remove("accountKey"); + settingJson.remove("appAuthToken"); + setting.setSettingJson(settingJson.isEmpty() ? null : settingJson.toString()); } catch (RuntimeException exception) { - throw new RuntimeException("支付宝支付配置无效"); + throw new RuntimeException("支付配置JSON无效"); } } /** - * 复制并脱敏支付配置,保证调用方不能通过返回对象修改缓存或内部商户配置。 + * 复制支付配置并剔除关系表负责的渠道字段,保证客户端不会读到旧配置或完整授权令牌。 * * @param source 内部完整支付配置 * @param 支付配置实体或视图类型 @@ -209,18 +206,10 @@ public class MchPaySettingServiceImpl extends ServiceImpl - \ No newline at end of file + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java index b9f71d52..ee0513a7 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java @@ -23,7 +23,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** - * 商户支付配置支付宝授权令牌脱敏和更新保留测试。 + * 商户支付配置关系字段清理测试。 */ @ExtendWith(MockitoExtension.class) class MchPaySettingServiceImplTest { @@ -57,31 +57,26 @@ class MchPaySettingServiceImplTest { } /** - * 验证管理端详情保留 smid 和账号键,但不返回完整授权令牌。 + * 验证管理端详情不再返回已经迁入关系表的任何渠道字段。 */ @Test - void getSafeByIdShouldRemoveAliPayAuthToken() { + void getSafeByIdShouldRemoveRelationOwnedFields() { MchPaySettingEntity stored = storedAliPaySetting(); when(mchPaySettingMapper.selectById(stored.getId())).thenReturn(stored); MchPaySettingEntity result = service.getSafeById(stored.getId()); - MchPaySettingEntity.AliPaySetting safe = JSONUtil.toBean( - result.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - Assertions.assertEquals("smid-1001", safe.getSubMchid()); - Assertions.assertEquals("secondary", safe.getAccountKey()); - Assertions.assertNull(safe.getAppAuthToken()); + Assertions.assertNull(result.getSettingJson()); Assertions.assertTrue(stored.getSettingJson().contains("sensitive-auth-token")); } /** - * 验证前端提交空 token 时保存旧值,但接口返回值仍保持脱敏。 + * 验证旧管理端继续提交关系字段时,钱包持久化边界仍会全部剔除。 */ @Test - void editShouldPreserveStoredAliPayAuthTokenWithoutReturningIt() { + void editShouldDiscardLegacyRelationOwnedFields() { MchPaySettingEntity stored = storedAliPaySetting(); when(mchPaySettingMapper.selectCount(any())).thenReturn(1L); - when(mchPaySettingMapper.selectById(stored.getId())).thenReturn(stored); when(mchManageService.join(stored.getMchId())).thenReturn(new com.bsd.pjcloud.wallet.api.vo.MchManageVo()); when(thirdAppPlusPayService.remoterChangeMch(any())).thenReturn(R.ok(true)); MchPaySettingEntity.AliPaySetting incomingSetting = new MchPaySettingEntity.AliPaySetting(); @@ -98,12 +93,8 @@ class MchPaySettingServiceImplTest { ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(MchPaySettingEntity.class); verify(mchPaySettingMapper).updateById(updateCaptor.capture()); - MchPaySettingEntity.AliPaySetting persisted = JSONUtil.toBean( - updateCaptor.getValue().getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - Assertions.assertEquals("sensitive-auth-token", persisted.getAppAuthToken()); - MchPaySettingEntity.AliPaySetting safeResult = JSONUtil.toBean( - result.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - Assertions.assertNull(safeResult.getAppAuthToken()); + Assertions.assertNull(updateCaptor.getValue().getSettingJson()); + Assertions.assertNull(result.getSettingJson()); } /** From 417c9d5754b1f2319bcaeb5242bd05bcb00abfc8 Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Wed, 26 Aug 2026 15:12:17 +0800 Subject: [PATCH 04/31] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=A7=A3=E5=AF=86?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/bsd/pjcloud/admin/controller/SysUserController.java | 1 + 1 file changed, 1 insertion(+) 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 5c8dc258..ca8f2443 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 @@ -405,6 +405,7 @@ public class SysUserController { * @return success/false */ @SysLog("修改个人信息") + @ApiCryptoAes @PutMapping("/personal/edit") public R updateUserInfo(@Valid @RequestBody UserDTO userDto) { if (StrUtil.isBlank(userDto.getUsername())) { From 4bf5930e64304e767c5c7a9d7299b40f445a7171 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Wed, 26 Aug 2026 15:44:15 +0800 Subject: [PATCH 05/31] =?UTF-8?q?feat:=E8=AE=A2=E5=8D=95=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E4=BC=98=E5=8C=96=20feat:=E5=88=86=E8=B4=A6=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...60823_add_hard_order_verification_time.sql | 2 + .../hard/api/dto/order/HardOrderDto.java | 6 + .../order/excel/HardOrderExportExcelVo.java | 166 ++++ .../controller/order/HardOrderController.java | 2 +- .../hard/service/HardOrderExportService.java | 2 +- .../order/HardOrderExportServiceImpl.java | 720 ++++++++---------- .../mapper/order/HardOrderMapper.xml | 17 + .../dto/pay/ThirdPayTotalQueryDto.java | 34 + .../thirdparty/vo/pay/ThirdPayTotalRowVo.java | 60 ++ .../vo/pay/ThirdPayTotalStatisticsVo.java | 21 + .../vo/pay/ThirdPayTotalSummaryVo.java | 12 + .../pay/ThirdPayTotalController.java | 43 ++ .../mapper/pay/ThirdPayTotalMapper.java | 51 ++ .../service/ThirdPayTotalService.java | 18 + .../impl/pay/ThirdPayTotalServiceImpl.java | 556 ++++++++++++++ .../ThirdPayTotalAggregateRecord.java | 24 + .../ThirdPayTotalFzRefundRecord.java | 28 + .../statistics/ThirdPayTotalRefundRecord.java | 31 + .../ThirdPayTotalStatisticsSupport.java | 22 + .../resources/mapper/ThirdPayTotalMapper.xml | 98 +++ .../ThirdPayTotalMapperSqlContractTest.java | 117 +++ .../pay/ThirdPayTotalServiceImplTest.java | 254 ++++++ 22 files changed, 1892 insertions(+), 392 deletions(-) create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardOrderExportExcelVo.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdPayTotalQueryDto.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayTotalRowVo.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayTotalStatisticsVo.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayTotalSummaryVo.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayTotalController.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayTotalMapper.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayTotalService.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayTotalServiceImpl.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalAggregateRecord.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalFzRefundRecord.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalRefundRecord.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalStatisticsSupport.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPayTotalMapper.xml create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayTotalMapperSqlContractTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayTotalServiceImplTest.java diff --git a/db/20260823_add_hard_order_verification_time.sql b/db/20260823_add_hard_order_verification_time.sql index 344c3c96..b4421205 100644 --- a/db/20260823_add_hard_order_verification_time.sql +++ b/db/20260823_add_hard_order_verification_time.sql @@ -14,3 +14,5 @@ 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/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 732377b4..612703dd 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,6 +38,9 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn private List mchTypeCodes; + /** 页面选择的商户类型编码集合;与服务端注入的商户类型权限范围同时生效。 */ + private List merchantTypeCodes; + private List orderStatusCodes; private LocalDateTime[] createTimeList; @@ -82,6 +85,9 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn private List hardTypeCodes; + /** 用餐时间段字典编码集合;为空表示不限制餐段。 */ + private List hourTypeCodes; + private List refundStatusCodes; /** diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardOrderExportExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardOrderExportExcelVo.java new file mode 100644 index 00000000..8f4a4c03 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardOrderExportExcelVo.java @@ -0,0 +1,166 @@ +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 com.alibaba.excel.annotation.write.style.ContentFontStyle; +import com.alibaba.excel.annotation.write.style.HeadFontStyle; +import com.alibaba.excel.annotation.write.style.HeadRowHeight; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 硬件订单明细导出行,按订单业务域组织二级表头。 + * + *

仅保留业务核对所需的用户、身份、订单、支付、核销和退款信息,隐藏内部主键及审计字段; + * 金额字段统一使用元。该对象仅用于导出展示,不参与数据库持久化和接口入参。

+ */ +@Data +@ColumnWidth(15) +@HeadRowHeight(15) +@HeadFontStyle(fontHeightInPoints = 10) +@ContentFontStyle(fontHeightInPoints = 10) +public class HardOrderExportExcelVo { + + /** 当前订单在本次导出结果中的一基序号;合计行为空。 */ + @ExcelProperty({"订单", "序号"}) + private String lineNumber; + + /** 订单类型中文名称;未知历史编码保留原始值。 */ + @ExcelProperty({"订单", "订单类型"}) + private String orderType; + + /** 订单状态中文名称;未知历史编码保留原始值。 */ + @ExcelProperty({"订单", "订单状态"}) + private String orderStatus; + + /** 统计场景中的订单来源名称;不属于统计来源时为空。 */ + @ExcelProperty({"订单", "订单来源"}) + private String saleSourceName; + + /** 订单创建时间,格式为 yyyy-MM-dd HH:mm:ss。 */ + @ExcelProperty({"订单", "下单时间"}) + @ColumnWidth(22) + private String createTime; + + /** 用户姓名,优先取 UPMS 姓名,其次取昵称、账号和订单历史展示名。 */ + @ExcelProperty({"用户", "姓名"}) + private String userName; + + /** 用户默认身份手机号;UPMS 资料缺失时回退订单历史手机号。 */ + @ExcelProperty({"用户", "手机号"}) + private String phone; + + /** 用户当前所属部门名称;UPMS 资料缺失时回退订单历史部门名称。 */ + @ExcelProperty({"用户", "部门"}) + private String deptName; + + /** 用户岗位名称,多个岗位使用中文逗号分隔。 */ + @ExcelProperty({"用户", "岗位"}) + private String postNames; + + /** 用户拥有的身份中文名称,多个身份使用中文逗号分隔。 */ + @ExcelProperty({"身份", "身份类型"}) + private String identityTypeNames; + + /** 学生身份学号;没有学生身份时为空。 */ + @ExcelProperty({"身份", "学号"}) + private String studentNo; + + /** 学生身份学校名称;没有学生身份时为空。 */ + @ExcelProperty({"身份", "学校"}) + private String schoolName; + + /** 学生身份班级名称;没有学生身份时为空。 */ + @ExcelProperty({"身份", "班级"}) + private String className; + + /** 学生身份监护人手机号,多个号码沿用业务存储格式。 */ + @ExcelProperty({"身份", "监护人手机号"}) + private String guardianPhones; + + /** 老师身份工号或学号;没有老师身份时为空。 */ + @ExcelProperty({"身份", "老师学号"}) + private String teacherNo; + + /** 钱包服务返回的商户名称;历史商户不存在时回退订单查询结果。 */ + @ExcelProperty({"商户设备", "商户"}) + private String merchantName; + + /** 订单关联设备名称;预约核销订单中用于识别核销机器。 */ + @ExcelProperty({"商户设备", "核销机器"}) + private String hardName; + + /** 实际关联设备的设备类型中文名称;未知历史编码保留原始值。 */ + @ExcelProperty({"商户设备", "设备类型"}) + private String hardType; + + /** 订单商品摘要,来源于下单时保存的商品描述。 */ + @ExcelProperty({"订单", "商品内容"}) + @ColumnWidth(24) + private String productDesc; + + /** 用餐时间段中文名称;未知历史编码保留原始值。 */ + @ExcelProperty({"订单", "用餐时段"}) + private String hourType; + + /** 预约目标用餐日期;非预约订单可能为下单日期或空值,页面统一称为预约时间。 */ + @ExcelProperty({"订单", "预约时间"}) + @ColumnWidth(22) + private LocalDate apptUseDate; + + /** 支付方式中文名称;未知历史编码保留原始值。 */ + @ExcelProperty({"支付", "支付方式"}) + private String payType; + + /** 商品总价,单位为元并保留两位小数。 */ + @ExcelProperty({"支付", "商品总价(元)"}) + @NumberFormat("#,##0.00") + private BigDecimal totalPrice; + + /** 额外费用,单位为元并保留两位小数。 */ + @ExcelProperty({"支付", "额外支付(元)"}) + @NumberFormat("#,##0.00") + private BigDecimal extraPrice; + + /** 配送费用,单位为元并保留两位小数。 */ + @ExcelProperty({"支付", "配送费(元)"}) + @NumberFormat("#,##0.00") + private BigDecimal deliveryPrice; + + /** 实际支付金额,单位为元并保留两位小数。 */ + @ExcelProperty({"支付", "实际支付(元)"}) + @NumberFormat("#,##0.00") + private BigDecimal payPrice; + + /** 订单实际金额,单位为元,按实际支付金额扣除退款金额计算。 */ + @ExcelProperty({"支付", "订单实际(元)"}) + @NumberFormat("#,##0.00") + private BigDecimal orderActualPrice; + + /** 支付完成时间,格式为 yyyy-MM-dd HH:mm:ss;未支付时为空。 */ + @ExcelProperty({"支付", "支付时间"}) + @ColumnWidth(22) + private String payTime; + + /** 订单实际核销完成时间,格式为 yyyy-MM-dd HH:mm:ss。 */ + @ExcelProperty({"核销", "核销时间"}) + @ColumnWidth(22) + private String verificationTime; + + /** 订单退款状态中文名称;未记录时为空,未知历史编码保留原始值。 */ + @ExcelProperty({"退款", "退款状态"}) + private String refundStatus; + + /** 已退款金额,单位为元并保留两位小数。 */ + @ExcelProperty({"退款", "退款金额(元)"}) + @NumberFormat("#,##0.00") + private BigDecimal refundPrice; + + /** 退款完成时间,格式为 yyyy-MM-dd HH:mm:ss。 */ + @ExcelProperty({"退款", "退款完成时间"}) + @ColumnWidth(22) + private String refundTime; +} 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 c770ec0a..ebf7559f 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 @@ -86,7 +86,7 @@ public class HardOrderController { private final HardOrderSyncService hardOrderSyncService; private final HardCanteenTotalService canteenTotalService; - /** 订单导出服务,负责异步任务、分批模板写入和文件中心上传。 */ + /** 订单导出服务,负责异步任务、EasyExcel 分片写入和文件中心上传。 */ private final HardOrderExportService hardOrderExportService; private final HardOrderMapper hardOrderMapper; diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderExportService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderExportService.java index 037c8de2..66f3c8d9 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderExportService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderExportService.java @@ -7,7 +7,7 @@ import jakarta.servlet.http.HttpServletResponse; /** * 硬件订单导出服务。 * - *

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

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

*/ public interface HardOrderExportService { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java index a0da9b17..93a4b221 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java @@ -1,9 +1,9 @@ package com.bsd.pjcloud.hard.service.impl.order; -import cn.hutool.core.date.DatePattern; -import cn.hutool.core.date.LocalDateTimeUtil; -import cn.hutool.core.io.resource.ResourceUtil; import cn.hutool.core.util.StrUtil; +import com.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; @@ -22,11 +22,16 @@ import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum; import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate; import com.bsd.pjcloud.common.security.util.SecurityUtils; +import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; +import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum; import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto; import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardOrderExportExcelVo; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.HardManagerService; import com.bsd.pjcloud.hard.service.HardOrderExportService; @@ -36,13 +41,6 @@ import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.poi.ss.usermodel.Cell; -import org.apache.poi.ss.usermodel.CellStyle; -import org.apache.poi.ss.usermodel.Row; -import org.apache.poi.ss.usermodel.Sheet; -import org.apache.poi.ss.util.CellRangeAddress; -import org.apache.poi.xssf.streaming.SXSSFSheet; -import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.springframework.beans.BeanUtils; import org.springframework.mock.web.MockMultipartFile; import org.springframework.stereotype.Service; @@ -52,6 +50,7 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.Path; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; @@ -65,139 +64,132 @@ import java.util.stream.Collectors; * 硬件订单导出服务实现。 * *

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

+ * 并在同一任务线程同步补充完整用户资料和商户名称。使用 EasyExcel 自定义二级表头分片写入, + * 完成后上传文件中心并返回下载地址,不再依赖固定 XLSX 模板。

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

* * @param response 当前 HTTP 下载响应 - * @param dto 订单页面当前筛选条件 + * @param dto 订单页面当前筛选条件 */ @Override public void export(HttpServletResponse response, HardOrderDto dto) { @@ -238,7 +230,7 @@ public class HardOrderExportServiceImpl implements HardOrderExportService { /** * 在异步线程中生成订单工作簿并上传文件中心。 * - * @param query 已冻结权限和筛选条件的订单查询 + * @param query 已冻结权限和筛选条件的订单查询 * @param reporter 当前异步任务进度报告器 * @return 文件中心下载地址和业务文件名 */ @@ -264,56 +256,76 @@ public class HardOrderExportServiceImpl implements HardOrderExportService { } /** - * 使用公共模板按页查询订单、流式写入明细和合计,并消费最终 XLSX 临时文件。 + * 按页查询订单并使用 EasyExcel 流式写入自定义二级表头工作簿。 * - * @param query 已冻结的订单查询条件 - * @param firstPage 已用于取得总数的第一页,避免重复查询首批订单 - * @param total 导出开始时按相同条件统计的订单总数 - * @param reporter 异步任务进度报告器;同步兼容导出时为空 + *

文件先写入临时路径,只有工作簿完整关闭后才交给上传或 HTTP 响应回调;每批写入后上报一次进度, + * 避免大批量订单长期占用请求线程和堆内存。

+ * + * @param query 已冻结的订单查询条件 + * @param firstPage 已用于取得总数的第一页,避免重复查询首批订单 + * @param total 导出开始时按相同条件统计的订单总数 + * @param reporter 异步任务进度报告器;同步兼容导出时为空 * @param fileConsumer 完整临时文件消费回调 - * @param 文件消费结果类型 + * @param 文件消费结果类型 * @return 文件消费回调返回的结果 */ private R writeExportWorkbook(HardOrderDto query, Page firstPage, long total, AsyncTaskProgressReporter reporter, BatchExcelExportTemplate.FileConsumer fileConsumer) { + Path outputPath = null; try { - return BatchExcelExportTemplate.builder(total) - .batchSize(ORDER_EXPORT_BATCH_SIZE) - .dataStartRowIndex(ORDER_DETAIL_START_ROW_INDEX) - .reservedTailRowCount(ORDER_RESERVED_TAIL_ROW_COUNT) - .temporaryFilePrefix("hard-order-export-") - .templateSupplier(() -> ResourceUtil.getStream(ORDER_EXPORT_TEMPLATE_PATH)) - .templateInitializer(workbook -> prepareTemplate(workbook, query)) - .pageFetcher((pageNumber, batchSize) -> pageNumber == 1L - ? safeRecords(firstPage) - : safeRecords(fetchExportPage(pageNumber, batchSize, query, false))) - .rowWriter((sheet, rowIndex, dataIndex, item, context) -> { - context.summary.add(item); - writeDetailRow(sheet, rowIndex, dataIndex, item, context); - }) - .footerWriter((sheet, rowIndex, processedTotal, context) -> - writeSummaryRow(sheet, rowIndex, context)) - .progressListener((processedTotal, expectedTotal) -> { - if (reporter != null) { - reporter.advance("已写入 " + processedTotal + " / " + expectedTotal + " 条订单"); - } - }) - .fileConsumer(fileConsumer) - .execute(); + outputPath = Files.createTempFile("hard-order-export-", ".xlsx"); + OrderExportSummary summary = new OrderExportSummary(); + long processedTotal = 0L; + long current = 1L; + try (ExcelWriter excelWriter = EasyExcel.write(outputPath.toFile(), HardOrderExportExcelVo.class).build()) { + WriteSheet writeSheet = EasyExcel.writerSheet(ORDER_EXPORT_SHEET_NAME).build(); + Page currentPage = firstPage; + while (processedTotal < total) { + List records = safeRecords(currentPage); + if (records.isEmpty()) { + break; + } + long remaining = total - processedTotal; + int recordCount = Math.toIntExact(Math.min(records.size(), remaining)); + List batch = records.subList(0, recordCount); + List excelRows = new ArrayList<>(batch.size()); + for (OrderExportRow item : batch) { + summary.add(item); + excelRows.add(toExcelRow(item, processedTotal + excelRows.size() + 1L)); + } + excelWriter.write(excelRows, writeSheet); + processedTotal += batch.size(); + if (reporter != null) { + reporter.advance("已写入 " + processedTotal + " / " + total + " 条订单"); + } + if (processedTotal >= total) { + break; + } + currentPage = fetchExportPage(++current, ORDER_EXPORT_BATCH_SIZE, query, false); + } + if (processedTotal != total) { + throw new IllegalStateException("订单数据在导出期间发生变化,预期 " + total + + " 条,实际处理 " + processedTotal + " 条"); + } + excelWriter.write(List.of(toSummaryExcelRow(summary)), writeSheet); + } + return fileConsumer.consume(outputPath); } catch (IllegalArgumentException | IllegalStateException exception) { throw exception; } catch (Exception exception) { throw new IllegalStateException("生成订单导出文件失败", exception); + } finally { + deleteTemporaryExportFile(outputPath); } } /** * 查询指定订单导出分页,数据范围和列表页保持为商户部门权限。 * - * @param current 从一开始的页码 - * @param size 单批最大订单数 - * @param query 已冻结的订单筛选条件 + * @param current 从一开始的页码 + * @param size 单批最大订单数 + * @param query 已冻结的订单筛选条件 * @param searchCount 是否执行总数查询,仅第一页需要 * @return 在当前线程同步装配完整用户资料和商户名称的导出分页 */ @@ -340,7 +352,7 @@ public class HardOrderExportServiceImpl implements HardOrderExportService { Map userMap = fetchExportUserMap(userIds); Map merchantNameMap = fetchMerchantNameMap(merchantIds); return orders.stream().map(order -> new OrderExportRow(order, userMap.get(order.getUserId()), - StrUtil.blankToDefault(merchantNameMap.get(order.getMerchantId()), order.getMerchantName()))) + StrUtil.blankToDefault(merchantNameMap.get(order.getMerchantId()), order.getMerchantName()))) .toList(); } @@ -433,211 +445,86 @@ public class HardOrderExportServiceImpl implements HardOrderExportService { } /** - * 读取模板明细、合计样式并移除占位行,为 SXSSF 连续追加订单腾出空间。 + * 将一条组合订单转换为二级表头导出对象。 * - * @param workbook 由既有订单模板创建的可编辑工作簿 - * @param query 已冻结的订单筛选条件,用于生成标题时间范围 - * @return 明细和合计写入共享的样式及金额汇总上下文 + * @param item 当前订单及其完整用户、商户资料 + * @param lineNumber 当前订单在全部导出结果中的一基序号 + * @return 已完成枚举翻译、金额和时间格式化的精简导出行 */ - private OrderExportTemplateContext prepareTemplate(XSSFWorkbook workbook, HardOrderDto query) { - Sheet sheet = workbook.getSheetAt(0); - Row titleRow = sheet.getRow(ORDER_TITLE_ROW_INDEX); - Row headerRow = sheet.getRow(ORDER_HEADER_ROW_INDEX); - Row detailTemplate = sheet.getRow(ORDER_DETAIL_TEMPLATE_ROW_INDEX); - Row summaryTemplate = sheet.getRow(ORDER_SUMMARY_TEMPLATE_ROW_INDEX); - if (titleRow == null || headerRow == null || detailTemplate == null || summaryTemplate == null) { - throw new IllegalStateException("订单导出模板缺少标题、表头、明细或合计占位行"); - } - extendTemplateColumns(sheet, titleRow, headerRow, detailTemplate, summaryTemplate); - CellStyle[] detailStyles = readRowStyles(detailTemplate); - CellStyle[] summaryStyles = readRowStyles(summaryTemplate); - short detailRowHeight = detailTemplate.getHeight(); - short summaryRowHeight = summaryTemplate.getHeight(); - - titleRow.getCell(0).setCellValue(buildTitle(query)); - for (int index = sheet.getNumMergedRegions() - 1; index >= 0; index--) { - CellRangeAddress mergedRegion = sheet.getMergedRegion(index); - if (mergedRegion.getFirstRow() == ORDER_TITLE_ROW_INDEX - || mergedRegion.getFirstRow() >= ORDER_SUMMARY_TEMPLATE_ROW_INDEX) { - sheet.removeMergedRegion(index); - } - } - sheet.addMergedRegion(new CellRangeAddress(ORDER_TITLE_ROW_INDEX, ORDER_TITLE_ROW_INDEX, - 0, ORDER_EXPORT_COLUMN_COUNT - 1)); - sheet.removeRow(summaryTemplate); - sheet.removeRow(detailTemplate); - return new OrderExportTemplateContext(detailStyles, summaryStyles, detailRowHeight, summaryRowHeight); - } - - /** - * 在内存中把既有十二列表格扩展为用户基础信息、身份信息和订单信息完整列。 - * - * @param sheet 订单模板工作表 - * @param titleRow 标题模板行 - * @param headerRow 表头模板行 - * @param detailTemplate 明细占位行 - * @param summaryTemplate 合计占位行 - */ - private void extendTemplateColumns(Sheet sheet, Row titleRow, Row headerRow, Row detailTemplate, - Row summaryTemplate) { - if (ORDER_DETAIL_STYLE_SOURCE_COLUMNS.length != ORDER_EXPORT_COLUMN_COUNT - || ORDER_SUMMARY_STYLE_SOURCE_COLUMNS.length != ORDER_EXPORT_COLUMN_COUNT - || ORDER_EXPORT_COLUMN_WIDTHS.length != ORDER_EXPORT_COLUMN_COUNT) { - throw new IllegalStateException("订单导出列配置数量不一致"); - } - Cell titleTemplateCell = titleRow.getCell(0); - Cell headerTemplateCell = headerRow.getCell(0); - if (titleTemplateCell == null || headerTemplateCell == null) { - throw new IllegalStateException("订单导出模板缺少标题或表头样式"); - } - CellStyle titleStyle = titleTemplateCell.getCellStyle(); - CellStyle headerStyle = headerTemplateCell.getCellStyle(); - CellStyle[] detailSourceStyles = readOriginalTemplateStyles(detailTemplate); - CellStyle[] summarySourceStyles = readOriginalTemplateStyles(summaryTemplate); - for (int column = 0; column < ORDER_EXPORT_COLUMN_COUNT; column++) { - Cell titleCell = getOrCreateCell(titleRow, column); - titleCell.setCellStyle(titleStyle); - Cell headerCell = getOrCreateCell(headerRow, column); - headerCell.setCellStyle(headerStyle); - headerCell.setCellValue(ORDER_EXPORT_HEADERS[column]); - getOrCreateCell(detailTemplate, column) - .setCellStyle(detailSourceStyles[ORDER_DETAIL_STYLE_SOURCE_COLUMNS[column]]); - getOrCreateCell(summaryTemplate, column) - .setCellStyle(summarySourceStyles[ORDER_SUMMARY_STYLE_SOURCE_COLUMNS[column]]); - sheet.setColumnWidth(column, ORDER_EXPORT_COLUMN_WIDTHS[column] * 256); - } - } - - /** - * 获取模板行单元格,不存在时按指定列创建。 - * - * @param row 模板行 - * @param column Excel 零基列号 - * @return 已存在或新建的单元格 - */ - private Cell getOrCreateCell(Row row, int column) { - Cell cell = row.getCell(column); - return cell == null ? row.createCell(column) : cell; - } - - /** - * 在扩展列之前读取原十二列表格的样式快照,避免映射过程中覆盖后续样式来源。 - * - * @param row 原模板明细或合计占位行 - * @return 按原 A-L 列顺序排列的样式快照 - */ - private CellStyle[] readOriginalTemplateStyles(Row row) { - CellStyle[] styles = new CellStyle[ORDER_ORIGINAL_TEMPLATE_COLUMN_COUNT]; - for (int column = 0; column < ORDER_ORIGINAL_TEMPLATE_COLUMN_COUNT; column++) { - Cell cell = row.getCell(column); - if (cell == null) { - throw new IllegalStateException("订单导出模板缺少第 " + (column + 1) + " 列样式"); - } - styles[column] = cell.getCellStyle(); - } - return styles; - } - - /** - * 读取模板行每一列的样式,确保新增行延续原模板的字体、边框和对齐方式。 - * - * @param row 明细或合计模板行 - * @return 按 A-U 列顺序排列的样式数组 - */ - private CellStyle[] readRowStyles(Row row) { - CellStyle[] styles = new CellStyle[ORDER_EXPORT_COLUMN_COUNT]; - for (int column = 0; column < ORDER_EXPORT_COLUMN_COUNT; column++) { - Cell cell = row.getCell(column); - if (cell != null) { - styles[column] = cell.getCellStyle(); - } - } - return styles; - } - - /** - * 把一条订单写入模板明细行,并在写入边界完成业务编码和金额单位转换。 - * - * @param sheet 订单导出工作表 - * @param rowIndex 当前 Excel 零基行号 - * @param dataIndex 当前订单在全部导出数据中的零基序号 - * @param item 当前订单及其完整用户、商户导出资料 - * @param context 模板样式和跨批次金额汇总上下文 - */ - private void writeDetailRow(SXSSFSheet sheet, int rowIndex, long dataIndex, OrderExportRow item, - OrderExportTemplateContext context) { + private HardOrderExportExcelVo toExcelRow(OrderExportRow item, long lineNumber) { HardOrderVo order = item.order; UserVO user = item.user; - Row row = sheet.createRow(rowIndex); - row.setHeight(context.detailRowHeight); - setCell(row, 0, context.detailStyles[0], dataIndex + 1L); - setCell(row, 1, context.detailStyles[1], item.merchantName); - setCell(row, 2, context.detailStyles[2], formatUserId(order.getUserId())); - setCell(row, 3, context.detailStyles[3], resolveUserName(user, order)); - setCell(row, 4, context.detailStyles[4], user == null ? null : user.getEmail()); - setCell(row, 5, context.detailStyles[5], user == null ? order.getUserDeptName() - : StrUtil.blankToDefault(user.getDeptName(), order.getUserDeptName())); - setCell(row, 6, context.detailStyles[6], user == null ? null : joinPostNames(user.getPostList())); - setCell(row, 7, context.detailStyles[7], user == null ? order.getUserMobile() + HardOrderExportExcelVo row = new HardOrderExportExcelVo(); + row.setLineNumber(Long.toString(lineNumber)); + row.setOrderType(resolveOrderType(order.getOrderType())); + row.setOrderStatus(resolveOrderStatus(order.getOrderStatus())); + row.setSaleSourceName(order.getSaleSourceName()); + row.setCreateTime(formatDateTime(order.getCreateTime())); + + row.setUserName(resolveUserName(user, order)); + row.setPhone(user == null ? order.getUserMobile() : StrUtil.blankToDefault(user.getPhone(), order.getUserMobile())); - setCell(row, 8, context.detailStyles[8], user == null ? null : joinIdentityNames(user.getIdentityTypes())); - setCell(row, 9, context.detailStyles[9], user == null || user.getStudentInfo() == null + row.setDeptName(user == null ? order.getUserDeptName() + : StrUtil.blankToDefault(user.getDeptName(), order.getUserDeptName())); + row.setPostNames(user == null ? null : joinPostNames(user.getPostList())); + row.setIdentityTypeNames(user == null ? null : joinIdentityNames(user.getIdentityTypes())); + row.setStudentNo(user == null || user.getStudentInfo() == null ? null : user.getStudentInfo().getStudentNo()); - setCell(row, 10, context.detailStyles[10], user == null || user.getStudentInfo() == null + row.setSchoolName(user == null || user.getStudentInfo() == null ? null : user.getStudentInfo().getSchoolName()); - setCell(row, 11, context.detailStyles[11], user == null || user.getStudentInfo() == null + row.setClassName(user == null || user.getStudentInfo() == null ? null : user.getStudentInfo().getClassName()); - setCell(row, 12, context.detailStyles[12], user == null || user.getStudentInfo() == null + row.setGuardianPhones(user == null || user.getStudentInfo() == null ? null : user.getStudentInfo().getGuardianPhones()); - setCell(row, 13, context.detailStyles[13], user == null || user.getTeacherInfo() == null + row.setTeacherNo(user == null || user.getTeacherInfo() == null ? null : user.getTeacherInfo().getTeacherNo()); - setCell(row, 14, context.detailStyles[14], order.getProductDesc()); - setCell(row, 15, context.detailStyles[15], resolvePayType(order.getPayType())); - setCell(row, 16, context.detailStyles[16], resolveHourType(order.getHourType())); - setCell(row, 17, context.detailStyles[17], formatPrice(order.getTotalPrice())); - setCell(row, 18, context.detailStyles[18], formatPrice(order.getExtraPrice())); - setCell(row, 19, context.detailStyles[19], formatPrice(order.getPayPrice())); - setCell(row, 20, context.detailStyles[20], formatDateTime(order.getPayTime())); + + row.setMerchantName(item.merchantName); + row.setHardName(order.getHardName()); + row.setHardType(resolveHardType(order.getHardType())); + + row.setProductDesc(order.getProductDesc()); + row.setHourType(resolveHourType(order.getHourType())); + row.setApptUseDate(order.getApptUseDate()); + + row.setPayType(resolvePayType(order.getPayType())); + row.setTotalPrice(formatPrice(order.getTotalPrice())); + row.setExtraPrice(formatPrice(order.getExtraPrice())); + row.setDeliveryPrice(formatPrice(order.getDeliveryPrice())); + row.setPayPrice(formatPrice(order.getPayPrice())); + row.setOrderActualPrice(formatOrderActualPrice(order.getPayPrice(), order.getRefundPrice())); + row.setPayTime(formatDateTime(order.getPayTime())); + + row.setVerificationTime(formatDateTime(order.getVerificationTime())); + + row.setRefundStatus(resolveRefundStatus(order.getRefundStatus())); + row.setRefundPrice(formatPrice(order.getRefundPrice())); + row.setRefundTime(formatDateTime(order.getRefundTime())); + return row; } /** - * 在所有订单明细之后写入金额合计,并合并金额列之前的说明区域。 + * 生成与明细相同结构的金额合计行,确保 EasyExcel 保持二级表头列顺序。 * - * @param sheet 订单导出工作表 - * @param rowIndex 合计行的 Excel 零基行号 - * @param context 模板样式和跨批次金额汇总上下文 + * @param summary 全部分片累计的订单金额 + * @return 首列显示“合计”且金额列写入元金额的导出行 */ - private void writeSummaryRow(SXSSFSheet sheet, int rowIndex, OrderExportTemplateContext context) { - Row row = sheet.createRow(rowIndex); - row.setHeight(context.summaryRowHeight); - for (int column = 0; column < ORDER_EXPORT_COLUMN_COUNT; column++) { - setCell(row, column, context.summaryStyles[column], null); - } - row.getCell(0).setCellValue("合计"); - row.getCell(ORDER_TOTAL_PRICE_COLUMN_INDEX) - .setCellValue(context.summary.formatPrice(context.summary.totalPriceSum).doubleValue()); - row.getCell(ORDER_EXTRA_PRICE_COLUMN_INDEX) - .setCellValue(context.summary.formatPrice(context.summary.extraPriceSum).doubleValue()); - row.getCell(ORDER_PAY_PRICE_COLUMN_INDEX) - .setCellValue(context.summary.formatPrice(context.summary.payPriceSum).doubleValue()); - sheet.addMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, - ORDER_SUMMARY_MERGE_LAST_COLUMN_INDEX)); - } - - /** - * 将用户主键按文本写入 Excel,避免雪花主键超过双精度整数范围后发生尾数丢失。 - * - * @param userId UPMS 用户主键,允许为空 - * @return 十进制文本;输入为空时返回空 - */ - private String formatUserId(Long userId) { - return userId == null ? null : userId.toString(); + private HardOrderExportExcelVo toSummaryExcelRow(OrderExportSummary summary) { + HardOrderExportExcelVo row = new HardOrderExportExcelVo(); + row.setLineNumber("合计"); + row.setTotalPrice(summary.formatPrice(summary.totalPriceSum)); + row.setExtraPrice(summary.formatPrice(summary.extraPriceSum)); + row.setDeliveryPrice(summary.formatPrice(summary.deliveryPriceSum)); + row.setPayPrice(summary.formatPrice(summary.payPriceSum)); + row.setOrderActualPrice(summary.formatPrice(summary.payPriceSum.subtract(summary.refundPriceSum))); + row.setRefundPrice(summary.formatPrice(summary.refundPriceSum)); + return row; } /** * 依次按姓名、昵称、登录账号和订单历史展示名确定导出姓名。 * - * @param user UPMS 返回的完整用户资料,历史订单用户不存在时为空 + * @param user UPMS 返回的完整用户资料,历史订单用户不存在时为空 * @param order 当前订单,用于用户资料缺失时兼容历史展示字段 * @return 用户导出姓名;所有来源均为空时返回空文本 */ @@ -685,40 +572,57 @@ public class HardOrderExportServiceImpl implements HardOrderExportService { } /** - * 为单元格设置模板样式和值,空值仍创建单元格以保留完整边框。 + * 将订单类型协议编码转换为中文描述,未知历史编码原样保留。 * - * @param row 目标行 - * @param column 目标列零基下标 - * @param style 模板样式,允许为空 - * @param value 单元格展示值,数字保持数字类型,其余按文本写入 + * @param orderType 数据库和跨端接口使用的订单类型编码 + * @return 订单类型中文描述、原始未知编码或空值 */ - private void setCell(Row row, int column, CellStyle style, Object value) { - Cell cell = row.createCell(column); - if (style != null) { - cell.setCellStyle(style); - } - if (value instanceof Number number) { - cell.setCellValue(number.doubleValue()); - } else if (value != null) { - cell.setCellValue(value.toString()); + private String resolveOrderType(String orderType) { + if (StrUtil.isBlank(orderType)) { + return orderType; } + HardOrderTypeEnum value = HardOrderTypeEnum.valueOfByType(orderType); + return value == null ? orderType : value.getDescription(); } /** - * 生成订单导出标题;仅支付时间范围沿用旧模板的标题展示规则。 + * 将订单状态协议编码安全转换为中文描述。 * - * @param query 订单筛选条件 - * @return 包含支付时间范围的订单交易汇总标题,未选择范围时返回固定标题 + *

既有枚举解析方法对未知编码会抛异常,因此导出侧遍历枚举匹配,确保历史脏数据仍可导出。

+ * + * @param orderStatus 数据库和跨端接口使用的订单状态编码 + * @return 订单状态中文描述、原始未知编码或空值 */ - private String buildTitle(HardOrderDto query) { - if (query.getPayTimeList() != null && query.getPayTimeList().length == 2 - && query.getPayTimeList()[0] != null && query.getPayTimeList()[1] != null) { - return LocalDateTimeUtil.format(query.getPayTimeList()[0], DatePattern.NORM_DATETIME_PATTERN) - + " 到 " - + LocalDateTimeUtil.format(query.getPayTimeList()[1], DatePattern.NORM_DATETIME_PATTERN) - + "订单交易汇总"; + private String resolveOrderStatus(String orderStatus) { + if (StrUtil.isBlank(orderStatus)) { + return orderStatus; } - return "订单交易汇总"; + for (HardOrderStatusEnum value : HardOrderStatusEnum.values()) { + if (Objects.equals(value.getType(), orderStatus)) { + return value.getDescription(); + } + } + return orderStatus; + } + + /** + * 将设备类型协议编码安全转换为中文描述。 + * + *

既有枚举解析方法对未知编码会抛异常,因此导出侧遍历枚举匹配并保留历史编码。

+ * + * @param hardType 硬件设备类型编码 + * @return 设备类型中文描述、原始未知编码或空值 + */ + private String resolveHardType(String hardType) { + if (StrUtil.isBlank(hardType)) { + return hardType; + } + for (HardTypeEnum value : HardTypeEnum.values()) { + if (Objects.equals(value.getType(), hardType)) { + return value.getDescription(); + } + } + return hardType; } /** @@ -736,17 +640,35 @@ public class HardOrderExportServiceImpl implements HardOrderExportService { } /** - * 将餐段协议编码转换为现有枚举中文描述,未知编码沿用旧导出规则输出空文本。 + * 将餐段协议编码转换为现有枚举中文描述,未知历史编码原样保留。 * * @param hourType 用餐时间段协议编码 - * @return 餐段中文描述、空值或空文本 + * @return 餐段中文描述、原始未知编码或空值 */ private String resolveHourType(String hourType) { if (StrUtil.isBlank(hourType)) { return hourType; } HardHourTypeEnum value = HardHourTypeEnum.valueOfByType(hourType); - return value == null ? "" : value.getDescription(); + return value == null ? hourType : value.getDescription(); + } + + /** + * 将订单退款状态编码转换为中文名称,未知历史编码原样保留。 + * + * @param refundStatus hard_order.refund_status 中保存的退款状态编码 + * @return 退款状态中文名称、原始未知编码或空值 + */ + private String resolveRefundStatus(String refundStatus) { + if (StrUtil.isBlank(refundStatus)) { + return refundStatus; + } + for (HardRefundStatusEnum value : HardRefundStatusEnum.values()) { + if (Objects.equals(value.getStatus(), refundStatus)) { + return value.getName(); + } + } + return refundStatus; } /** @@ -761,24 +683,37 @@ public class HardOrderExportServiceImpl implements HardOrderExportService { } /** - * 将订单支付时间转换为模板使用的标准日期时间文本。 + * 计算订单实际金额并转换为元,退款为空时按零分处理,允许结果为负数以保留真实差额。 * - * @param dateTime 订单支付时间,允许为空 + * @param payPrice 订单实际支付金额,单位为分;允许为空 + * @param refundPrice 订单已退款金额,单位为分;允许为空 + * @return 订单实际金额,单位为元并保留两位小数 + */ + private BigDecimal formatOrderActualPrice(Number payPrice, Number refundPrice) { + BigDecimal paid = payPrice == null ? BigDecimal.ZERO : BigDecimal.valueOf(payPrice.longValue()); + BigDecimal refunded = refundPrice == null ? BigDecimal.ZERO : BigDecimal.valueOf(refundPrice.longValue()); + return paid.subtract(refunded).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + } + + /** + * 将订单日期时间转换为 Excel 统一使用的标准文本。 + * + * @param dateTime 订单业务日期时间,允许为空 * @return yyyy-MM-dd HH:mm:ss 文本;输入为空时返回空 */ private String formatDateTime(LocalDateTime dateTime) { - return dateTime == null ? null : LocalDateTimeUtil.format(dateTime, DatePattern.NORM_DATETIME_PATTERN); + return dateTime == null ? null : dateTime.format(EXCEL_DATE_TIME_FORMATTER); } /** * 上传完整订单工作簿并校验文件中心业务响应。 * - * @param file 已关闭并可读取的完整 XLSX 临时文件 + * @param file 已关闭并可读取的完整 XLSX 临时文件 * @param fileName 用户下载时使用的文件名 * @return 文件中心提供的下载地址 * @throws Exception 文件读取或远程上传失败时抛出 */ - private String uploadExportFile(java.nio.file.Path file, String fileName) throws Exception { + private String uploadExportFile(Path file, String fileName) throws Exception { MultipartFile exportFile = new MockMultipartFile("file", encodeUploadOriginalFilename(fileName), ORDER_EXPORT_CONTENT_TYPE, Files.readAllBytes(file)); R uploadResult = remoteFileService.innerUpload(exportFile, ORDER_EXPORT_FILE_DIR, null, @@ -804,6 +739,22 @@ public class HardOrderExportServiceImpl implements HardOrderExportService { return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); } + /** + * 清理订单导出临时文件;清理失败只记录告警,不覆盖原始导出或上传结果。 + * + * @param outputPath 待删除的临时 XLSX 路径,允许为空 + */ + private void deleteTemporaryExportFile(Path outputPath) { + if (outputPath == null) { + return; + } + try { + Files.deleteIfExists(outputPath); + } catch (Exception exception) { + log.warn("|{}|清理临时文件失败,path:{}", LOG_TAG, outputPath, exception); + } + } + /** * 冻结当前用户允许查看的设备类型、身份用户范围和单商户的子商户范围。 * @@ -872,11 +823,13 @@ public class HardOrderExportServiceImpl implements HardOrderExportService { target.setHardIds(copyList(source.getHardIds())); target.setMchIds(copyList(source.getMchIds())); target.setMchTypeCodes(copyList(source.getMchTypeCodes())); + target.setMerchantTypeCodes(copyList(source.getMerchantTypeCodes())); target.setOrderStatusCodes(copyList(source.getOrderStatusCodes())); target.setOrderTypeCodes(copyList(source.getOrderTypeCodes())); target.setOrderPayTypeCodes(copyList(source.getOrderPayTypeCodes())); target.setExcludeOrderPayTypeCodes(copyList(source.getExcludeOrderPayTypeCodes())); target.setHardTypeCodes(copyList(source.getHardTypeCodes())); + target.setHourTypeCodes(copyList(source.getHourTypeCodes())); target.setRefundStatusCodes(copyList(source.getRefundStatusCodes())); target.setSaleSourceCodes(copyList(source.getSaleSourceCodes())); target.setSaleStatusCodes(copyList(source.getSaleStatusCodes())); @@ -899,7 +852,7 @@ public class HardOrderExportServiceImpl implements HardOrderExportService { * 复制可变列表,保留空值语义并隔离调用方后续修改。 * * @param source 原始列表,允许为空 - * @param 列表元素类型 + * @param 列表元素类型 * @return 独立列表副本;输入为空时返回空 */ private List copyList(List source) { @@ -946,62 +899,31 @@ public class HardOrderExportServiceImpl implements HardOrderExportService { return target; } - /** - * 订单导出模板样式和跨批次金额汇总上下文。 - */ - private static final class OrderExportTemplateContext { - - /** 明细行 A-U 列的模板样式。 */ - private final CellStyle[] detailStyles; - - /** 合计行 A-U 列的模板样式。 */ - private final CellStyle[] summaryStyles; - - /** 明细模板行高度,单位为 POI twip。 */ - private final short detailRowHeight; - - /** 合计模板行高度,单位为 POI twip。 */ - private final short summaryRowHeight; - - /** 跨分页累计的订单金额汇总。 */ - private final OrderExportSummary summary = new OrderExportSummary(); - - /** - * 创建订单导出模板上下文。 - * - * @param detailStyles 明细行 A-U 列样式 - * @param summaryStyles 合计行 A-U 列样式 - * @param detailRowHeight 明细行高度 - * @param summaryRowHeight 合计行高度 - */ - private OrderExportTemplateContext(CellStyle[] detailStyles, CellStyle[] summaryStyles, - short detailRowHeight, short summaryRowHeight) { - this.detailStyles = detailStyles; - this.summaryStyles = summaryStyles; - this.detailRowHeight = detailRowHeight; - this.summaryRowHeight = summaryRowHeight; - } - } - /** * 单条订单导出行,组合数据库订单、UPMS 完整用户资料和钱包商户名称。 */ private static final class OrderExportRow { - /** 当前数据权限和筛选条件命中的订单记录。 */ + /** + * 当前数据权限和筛选条件命中的订单记录。 + */ private final HardOrderVo order; - /** UPMS 返回的完整用户导出资料;历史用户不存在或不可见时为空。 */ + /** + * UPMS 返回的完整用户导出资料;历史用户不存在或不可见时为空。 + */ private final UserVO user; - /** 钱包服务返回的商户名称;历史商户不存在时为空。 */ + /** + * 钱包服务返回的商户名称;历史商户不存在时为空。 + */ private final String merchantName; /** * 创建订单导出行。 * - * @param order 当前订单记录 - * @param user 与订单用户主键匹配的完整用户资料 + * @param order 当前订单记录 + * @param user 与订单用户主键匹配的完整用户资料 * @param merchantName 与订单商户主键匹配的商户名称 */ private OrderExportRow(HardOrderVo order, UserVO user, String merchantName) { @@ -1016,31 +938,49 @@ public class HardOrderExportServiceImpl implements HardOrderExportService { */ private static final class OrderExportSummary { - /** 商品总价累计值,单位为分。 */ + /** + * 商品总价累计值,单位为分。 + */ private BigDecimal totalPriceSum = BigDecimal.ZERO; - /** 额外支付累计值,单位为分。 */ + /** + * 额外支付累计值,单位为分。 + */ private BigDecimal extraPriceSum = BigDecimal.ZERO; - /** 实际支付累计值,单位为分。 */ + /** + * 配送费用累计值,单位为分。 + */ + private BigDecimal deliveryPriceSum = BigDecimal.ZERO; + + /** + * 实际支付累计值,单位为分。 + */ private BigDecimal payPriceSum = BigDecimal.ZERO; /** - * 累计当前订单的三个金额字段,历史空值按零分处理。 + * 退款金额累计值,单位为分。 + */ + private BigDecimal refundPriceSum = BigDecimal.ZERO; + + /** + * 累计当前订单的商品、额外、配送、实付和退款金额,历史空值按零分处理。 * * @param item 当前订单及其导出关联资料 */ private void add(OrderExportRow item) { totalPriceSum = addAmount(totalPriceSum, item.order.getTotalPrice()); extraPriceSum = addAmount(extraPriceSum, item.order.getExtraPrice()); + deliveryPriceSum = addAmount(deliveryPriceSum, item.order.getDeliveryPrice()); payPriceSum = addAmount(payPriceSum, item.order.getPayPrice()); + refundPriceSum = addAmount(refundPriceSum, item.order.getRefundPrice()); } /** * 将非空分金额加入当前累计值。 * * @param current 当前累计金额,单位为分 - * @param amount 待累计金额,单位为分;允许为空 + * @param amount 待累计金额,单位为分;允许为空 * @return 累加后的分金额 */ private BigDecimal addAmount(BigDecimal current, Number amount) { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml index 0afa5b1f..ec7fddf7 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml @@ -52,11 +52,15 @@ a.refund_status, a.verification_time, a.apply_cancel_time, + a.unfollow_time, a.android_order_code, a.is_offline_order, a.product_desc, a.offline_pay_success, a.address_name, + a.shipment_no, + a.shipped_time, + a.delivered_time, a.create_by, a.create_time, a.update_by, @@ -90,6 +94,13 @@ and a.merchant_type = #{dto.merchantType} + + + and a.merchant_type in + + #{v} + + and a.order_type = #{dto.orderType} @@ -102,6 +113,12 @@ and a.hour_type = #{dto.hourType} + + and a.hour_type in + + #{v} + + and ( a.hour_type is not null and a.hour_type not in ('-1') ) diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdPayTotalQueryDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdPayTotalQueryDto.java new file mode 100644 index 00000000..8dc07289 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdPayTotalQueryDto.java @@ -0,0 +1,34 @@ +package com.bsd.pjcloud.thirdparty.dto.pay; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 第三方支付统计查询条件。 + * + *

统计始终按支付订单的支付时间归属,时间范围由管理端明确传入,避免空条件扫描全历史支付数据。

+ */ +@Data +@Schema(description = "第三方支付统计查询条件") +public class ThirdPayTotalQueryDto { + + /** + * 支付所属商户主键;为空表示查询当前权限范围内全部商户。 + */ + @Schema(description = "商户ID") + private Long mchId; + + /** + * 支付平台类型编码,沿用 mini_platform_type 字典;为空表示全部支付方式。 + */ + @Schema(description = "支付平台类型") + private String payPlatformType; + + /** + * 支付时间范围,必须包含开始和结束两个时间点,最大跨度一年。 + */ + @Schema(description = "支付时间范围") + private LocalDateTime[] payTimeList; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayTotalRowVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayTotalRowVo.java new file mode 100644 index 00000000..52a21a3b --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayTotalRowVo.java @@ -0,0 +1,60 @@ +package com.bsd.pjcloud.thirdparty.vo.pay; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 第三方支付统计的商户与支付方式明细。 + * + *

金额字段统一使用分传输;管理端展示时再转换为元。退款快照无法还原金额时, + * 对应不可核对数量会增加且不会把未知金额当作零。

+ */ +@Data +@Schema(description = "第三方支付统计明细") +public class ThirdPayTotalRowVo { + + /** 商户主键;历史订单缺失商户时允许为空。 */ + private Long mchId; + + /** 商户名称;远程商户服务不可用时返回商户信息不可用。 */ + private String mchName; + + /** 支付平台类型编码,未知历史编码原样保留。 */ + private String payPlatformType; + + /** 支付成功订单数,按支付订单主键去重。 */ + private Long payOrderCount = 0L; + + /** 支付成功金额,单位分。 */ + private Long payAmount = 0L; + + /** 退款成功订单数,按支付订单主键去重。 */ + private Long refundOrderCount = 0L; + + /** 可从退款快照解析出的退款金额,单位分。 */ + private Long refundAmount = 0L; + + /** 分账成功记录数。 */ + private Long fzSuccessCount = 0L; + + /** 分账成功金额,单位分。 */ + private Long fzSuccessAmount = 0L; + + /** 成功退分账记录数。 */ + private Long fzRefundSuccessCount = 0L; + + /** 可从退分账快照解析出的金额,单位分。 */ + private Long fzRefundSuccessAmount = 0L; + + /** 分账成功金额扣除已解析退分账金额后的平台净收款,单位分。 */ + private Long platformNetAmount; + + /** 退分账金额是否全部可由历史快照核对。 */ + private boolean platformNetAmountComplete = true; + + /** 退款成功但金额无法从历史快照解析的订单数。 */ + private Long refundAmountUnavailableCount = 0L; + + /** 成功退分账但金额无法从历史快照解析的记录数。 */ + private Long fzRefundAmountUnavailableCount = 0L; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayTotalStatisticsVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayTotalStatisticsVo.java new file mode 100644 index 00000000..5de713d0 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayTotalStatisticsVo.java @@ -0,0 +1,21 @@ +package com.bsd.pjcloud.thirdparty.vo.pay; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 第三方支付统计接口返回值。 + */ +@Data +@Schema(description = "第三方支付统计结果") +public class ThirdPayTotalStatisticsVo { + + /** 按商户和支付方式分组的明细,未知维度也必须保留。 */ + private List rows = new ArrayList<>(); + + /** 当前查询范围内全部明细的合计。 */ + private ThirdPayTotalSummaryVo total = new ThirdPayTotalSummaryVo(); +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayTotalSummaryVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayTotalSummaryVo.java new file mode 100644 index 00000000..60ea2308 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayTotalSummaryVo.java @@ -0,0 +1,12 @@ +package com.bsd.pjcloud.thirdparty.vo.pay; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 第三方支付统计范围合计。 + * + *

字段口径与明细一致,维度字段为空表示全部商户和支付方式。

+ */ +@Schema(description = "第三方支付统计合计") +public class ThirdPayTotalSummaryVo extends ThirdPayTotalRowVo { +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayTotalController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayTotalController.java new file mode 100644 index 00000000..8a7eebfc --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayTotalController.java @@ -0,0 +1,43 @@ +package com.bsd.pjcloud.thirdparty.controller.pay; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayTotalQueryDto; +import com.bsd.pjcloud.thirdparty.service.ThirdPayTotalService; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalStatisticsVo; +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; +import org.springframework.web.bind.annotation.RestController; + +/** + * 第三方支付统计接口。 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/third-pay-total") +@Tag(description = "third-pay-total", name = "第三方支付统计") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class ThirdPayTotalController { + + /** 第三方支付统计服务,负责校验范围并完成聚合解析。 */ + private final ThirdPayTotalService thirdPayTotalService; + + /** + * 按商户和支付方式统计支付订单、退款、分账和平台净收款。 + * + * @param query 统计查询条件,支付时间范围必须完整且不超过一年 + * @return 统一响应包装的统计结果 + */ + @PostMapping("/statistics") + @Operation(summary = "第三方支付统计", description = "按商户和支付方式统计支付、退款、分账及平台净收款") + @PreAuthorize("@pms.hasPermission('third_thirdPayTotal_view')") + public R statistics(@RequestBody ThirdPayTotalQueryDto query) { + return R.ok(thirdPayTotalService.statistics(query)); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayTotalMapper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayTotalMapper.java new file mode 100644 index 00000000..abe14c7d --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayTotalMapper.java @@ -0,0 +1,51 @@ +package com.bsd.pjcloud.thirdparty.mapper.pay; + +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayTotalQueryDto; +import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalAggregateRecord; +import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalFzRefundRecord; +import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalRefundRecord; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 第三方支付统计数据访问接口。 + * + *

支付和分账分别聚合,退款快照单独读取,避免支付订单与多条分账记录直接连接后重复累加金额。

+ */ +@Mapper +public interface ThirdPayTotalMapper { + + /** + * 查询支付成功订单的分组数量与金额。 + * + * @param query 已校验且包含完整支付时间范围的查询条件 + * @return 按商户和支付方式分组的成功支付聚合;无数据时返回空列表 + */ + List selectPaySuccessAggregates(@Param("query") ThirdPayTotalQueryDto query); + + /** + * 查询退款成功订单的历史快照,每个支付订单最多返回一条。 + * + * @param query 已校验且包含完整支付时间范围的查询条件 + * @return 退款成功订单及三级退款快照,供 Service 按渠道精确解析金额 + */ + List selectRefundRecords(@Param("query") ThirdPayTotalQueryDto query); + + /** + * 查询分账成功记录的分组数量与金额。 + * + * @param query 已校验且包含完整支付时间范围的查询条件 + * @return 按关联支付订单商户和支付方式分组的分账成功聚合 + */ + List selectFzSuccessAggregates(@Param("query") ThirdPayTotalQueryDto query); + + /** + * 查询成功退分账记录及其快照。 + * + * @param query 已校验且包含完整支付时间范围的查询条件 + * @return 每条成功退分账记录及其请求、响应快照 + */ + List selectFzRefundRecords(@Param("query") ThirdPayTotalQueryDto query); +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayTotalService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayTotalService.java new file mode 100644 index 00000000..81bed485 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayTotalService.java @@ -0,0 +1,18 @@ +package com.bsd.pjcloud.thirdparty.service; + +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayTotalQueryDto; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalStatisticsVo; + +/** + * 第三方支付统计服务。 + */ +public interface ThirdPayTotalService { + + /** + * 按商户和支付方式查询支付、退款、分账及平台净收款统计。 + * + * @param query 已校验的支付时间、商户和支付方式条件 + * @return 统计明细及范围合计,历史未知维度不会被丢弃 + */ + ThirdPayTotalStatisticsVo statistics(ThirdPayTotalQueryDto query); +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayTotalServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayTotalServiceImpl.java new file mode 100644 index 00000000..78def267 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayTotalServiceImpl.java @@ -0,0 +1,556 @@ +package com.bsd.pjcloud.thirdparty.service.impl.pay; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONArray; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayTotalQueryDto; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayTotalMapper; +import com.bsd.pjcloud.thirdparty.service.ThirdPayTotalService; +import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalAggregateRecord; +import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalFzRefundRecord; +import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalRefundRecord; +import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalStatisticsSupport; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalRowVo; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalStatisticsVo; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalSummaryVo; +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.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 第三方支付统计服务实现。 + * + *

支付成功、退款成功、分账成功和退分账成功分别查询;退款快照只保留当前退款事实, + * 因此金额无法还原时记录不可核对数量,并保留已解析的部分金额。

+ */ +@Service +@Validated +@Slf4j +@RequiredArgsConstructor +public class ThirdPayTotalServiceImpl implements ThirdPayTotalService { + + /** 统计查询 Mapper,负责按支付时间和当前租户数据聚合事实。 */ + private final ThirdPayTotalMapper thirdPayTotalMapper; + + /** 商户远程服务,用于批量补充统计行的商户名称。 */ + private final RemoteMchService remoteMchService; + + /** 微信支付平台编码,持久化于 third_pay_order.pay_platform_type。 */ + private static final String WECHAT_PLATFORM = "10"; + + /** 支付宝支付平台编码,持久化于 third_pay_order.pay_platform_type。 */ + private static final String ALIPAY_PLATFORM = "20"; + + /** 退款快照解析优先级,必须先读取最终回调再读取渠道响应和发起请求。 */ + private static final List WECHAT_REFUND_KEYS = List.of("refund_fee", "settlement_refund_fee", "refund"); + + /** 支付宝退款快照中的金额字段优先级,单位为元。 */ + private static final List ALIPAY_REFUND_KEYS = List.of("refund_fee", "send_back_fee", "refundAmount"); + + /** + * 统计指定支付时间范围内的四类支付事实,并在内存中合并到商户与支付方式维度。 + * + * @param query 已包含完整日期范围的查询条件 + * @return 统计明细及合计 + */ + @Override + public ThirdPayTotalStatisticsVo statistics(ThirdPayTotalQueryDto query) { + validateQuery(query); + Map rowMap = new LinkedHashMap<>(); + applyAggregates(rowMap, thirdPayTotalMapper.selectPaySuccessAggregates(query), true); + applyRefundRecords(rowMap, thirdPayTotalMapper.selectRefundRecords(query)); + applyAggregates(rowMap, thirdPayTotalMapper.selectFzSuccessAggregates(query), false); + applyFzRefundRecords(rowMap, thirdPayTotalMapper.selectFzRefundRecords(query)); + List rows = new ArrayList<>(rowMap.values()); + rows.sort(Comparator.comparing(ThirdPayTotalRowVo::getMchId, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(ThirdPayTotalRowVo::getPayPlatformType, Comparator.nullsLast(Comparator.naturalOrder()))); + fillMerchantNames(rows); + ThirdPayTotalSummaryVo total = summarize(rows); + ThirdPayTotalStatisticsVo result = new ThirdPayTotalStatisticsVo(); + result.setRows(rows); + result.setTotal(total); + return result; + } + + /** + * 校验时间范围,拒绝空范围和超过一年的全历史扫描。 + * + * @param query 统计查询条件 + */ + void validateQuery(ThirdPayTotalQueryDto query) { + if (query == null || query.getPayTimeList() == null || query.getPayTimeList().length != 2 + || query.getPayTimeList()[0] == null || query.getPayTimeList()[1] == null) { + throw new IllegalArgumentException("支付时间范围必须完整填写"); + } + LocalDateTime start = query.getPayTimeList()[0]; + LocalDateTime end = query.getPayTimeList()[1]; + if (start.isAfter(end)) { + throw new IllegalArgumentException("支付时间范围开始时间不能晚于结束时间"); + } + if (end.isAfter(start.plusYears(1))) { + throw new IllegalArgumentException("支付统计时间范围不能超过一年"); + } + } + + /** + * 将数据库中的支付成功或分账成功聚合结果写入内存行。 + * + * @param rowMap 统计行索引 + * @param records SQL 聚合结果 + * @param paySuccess 是否写入支付成功指标;否则写入分账成功指标 + */ + private void applyAggregates(Map rowMap, + List records, boolean paySuccess) { + if (records == null) return; + for (ThirdPayTotalAggregateRecord record : records) { + if (record == null) continue; + ThirdPayTotalRowVo row = row(rowMap, record.getMchId(), record.getPayPlatformType()); + if (paySuccess) { + row.setPayOrderCount(safeAdd(row.getPayOrderCount(), zeroIfNull(record.getRecordCount()))); + row.setPayAmount(safeAdd(row.getPayAmount(), zeroIfNull(record.getAmount()))); + } else { + row.setFzSuccessCount(safeAdd(row.getFzSuccessCount(), zeroIfNull(record.getRecordCount()))); + row.setFzSuccessAmount(safeAdd(row.getFzSuccessAmount(), zeroIfNull(record.getAmount()))); + } + } + } + + /** + * 归并退款成功订单并解析三级退款快照。 + * + * @param rowMap 统计行索引 + * @param records 退款成功订单快照列表 + */ + private void applyRefundRecords(Map rowMap, + List records) { + if (records == null) return; + for (ThirdPayTotalRefundRecord record : records) { + if (record == null) continue; + ThirdPayTotalRowVo row = row(rowMap, record.getMchId(), record.getPayPlatformType()); + row.setRefundOrderCount(safeAdd(row.getRefundOrderCount(), 1L)); + Long amount = parseRefundAmount(record); + if (amount == null) { + row.setRefundAmountUnavailableCount(safeAdd(row.getRefundAmountUnavailableCount(), 1L)); + } else { + row.setRefundAmount(safeAdd(row.getRefundAmount(), amount)); + } + } + } + + /** + * 归并成功退分账记录,解析未知金额时将净收款标为部分可核对。 + * + * @param rowMap 统计行索引 + * @param records 成功退分账快照列表 + */ + private void applyFzRefundRecords(Map rowMap, + List records) { + if (records == null) return; + for (ThirdPayTotalFzRefundRecord record : records) { + if (record == null) continue; + ThirdPayTotalRowVo row = row(rowMap, record.getMchId(), record.getPayPlatformType()); + row.setFzRefundSuccessCount(safeAdd(row.getFzRefundSuccessCount(), 1L)); + Long amount = parseFzRefundAmount(record); + if (amount == null) { + row.setFzRefundAmountUnavailableCount(safeAdd(row.getFzRefundAmountUnavailableCount(), 1L)); + row.setPlatformNetAmountComplete(false); + } else { + row.setFzRefundSuccessAmount(safeAdd(row.getFzRefundSuccessAmount(), amount)); + } + } + } + + /** + * 获取或创建单个统计维度行,并初始化平台净收款为已知的分账成功金额。 + * + * @param rowMap 统计行索引 + * @param mchId 商户主键 + * @param payPlatformType 支付方式编码 + * @return 可变统计行 + */ + private ThirdPayTotalRowVo row(Map rowMap, + Long mchId, String payPlatformType) { + ThirdPayTotalStatisticsSupport key = ThirdPayTotalStatisticsSupport.of(mchId, payPlatformType); + return rowMap.computeIfAbsent(key, ignored -> { + ThirdPayTotalRowVo value = new ThirdPayTotalRowVo(); + value.setMchId(mchId); + value.setPayPlatformType(key.payPlatformType()); + return value; + }); + } + + /** + * 为每行计算平台净收款;未知退分账金额只影响完整性标记,不被当成零扣除。 + * + * @param row 统计明细行 + */ + private void calculateNetAmount(ThirdPayTotalRowVo row) { + row.setPlatformNetAmount(safeAdd(row.getFzSuccessAmount(), -zeroIfNull(row.getFzRefundSuccessAmount()))); + } + + /** + * 批量补充商户名称,远程失败时不丢弃统计维度。 + * + * @param rows 统计明细 + */ + private void fillMerchantNames(List rows) { + if (rows == null || rows.isEmpty()) return; + List mchIds = rows.stream().map(ThirdPayTotalRowVo::getMchId).filter(id -> id != null).distinct().toList(); + Map merchantMap = new HashMap<>(); + if (!mchIds.isEmpty()) { + try { + R> response = remoteMchService.getByIds(mchIds); + if (response != null && response.isOk() && response.getData() != null) { + for (MchManageVo merchant : response.getData()) { + if (merchant != null && merchant.getId() != null && StrUtil.isNotBlank(merchant.getName())) { + merchantMap.putIfAbsent(merchant.getId(), merchant.getName()); + } + } + } + } catch (Exception exception) { + log.warn("第三方支付统计补充商户名称失败,errorType:{}", exception.getClass().getSimpleName()); + } + } + for (ThirdPayTotalRowVo row : rows) { + if (row.getMchId() == null) { + row.setMchName("未记录商户"); + } else { + row.setMchName(merchantMap.getOrDefault(row.getMchId(), "商户信息不可用")); + } + calculateNetAmount(row); + } + } + + /** + * 计算范围合计,完整性只要有一条明细不可核对就置为 false。 + * + * @param rows 明细列表 + * @return 合计行 + */ + private ThirdPayTotalSummaryVo summarize(List rows) { + ThirdPayTotalSummaryVo total = new ThirdPayTotalSummaryVo(); + if (rows == null) return total; + for (ThirdPayTotalRowVo row : rows) { + total.setPayOrderCount(safeAdd(total.getPayOrderCount(), zeroIfNull(row.getPayOrderCount()))); + total.setPayAmount(safeAdd(total.getPayAmount(), zeroIfNull(row.getPayAmount()))); + total.setRefundOrderCount(safeAdd(total.getRefundOrderCount(), zeroIfNull(row.getRefundOrderCount()))); + total.setRefundAmount(safeAdd(total.getRefundAmount(), zeroIfNull(row.getRefundAmount()))); + total.setFzSuccessCount(safeAdd(total.getFzSuccessCount(), zeroIfNull(row.getFzSuccessCount()))); + total.setFzSuccessAmount(safeAdd(total.getFzSuccessAmount(), zeroIfNull(row.getFzSuccessAmount()))); + total.setFzRefundSuccessCount(safeAdd(total.getFzRefundSuccessCount(), zeroIfNull(row.getFzRefundSuccessCount()))); + total.setFzRefundSuccessAmount(safeAdd(total.getFzRefundSuccessAmount(), zeroIfNull(row.getFzRefundSuccessAmount()))); + total.setRefundAmountUnavailableCount(safeAdd(total.getRefundAmountUnavailableCount(), zeroIfNull(row.getRefundAmountUnavailableCount()))); + total.setFzRefundAmountUnavailableCount(safeAdd(total.getFzRefundAmountUnavailableCount(), zeroIfNull(row.getFzRefundAmountUnavailableCount()))); + total.setPlatformNetAmountComplete(total.isPlatformNetAmountComplete() && row.isPlatformNetAmountComplete()); + } + calculateNetAmount(total); + return total; + } + + /** + * 从退款订单按回调、响应、请求优先级解析退款金额。 + * + * @param record 退款快照记录 + * @return 退款金额(分);无法解析时返回空 + */ + Long parseRefundAmount(ThirdPayTotalRefundRecord record) { + if (record == null) return null; + // Arrays.asList 允许历史快照为空;List.of 遇到任一 null 会在解析前直接抛出异常。 + List snapshots = Arrays.asList(record.getRefundCallbackRes(), record.getRefundRes(), record.getRefundRep()); + for (String snapshot : snapshots) { + Long amount = parsePlatformAmount(snapshot, record.getPayPlatformType()); + if (amount != null) return amount; + } + return null; + } + + /** + * 从微信或支付宝退分账快照解析金额;支付宝金额先以 BigDecimal 元转分。 + * + * @param record 退分账快照记录 + * @return 退分账金额(分);无法解析时返回空 + */ + Long parseFzRefundAmount(ThirdPayTotalFzRefundRecord record) { + if (record == null) return null; + if (WECHAT_PLATFORM.equals(record.getPayPlatformType())) { + Long amount = parseWechatFzAmount(record.getFzRefundRes()); + return amount != null ? amount : parseWechatFzAmount(record.getFzRefundReq()); + } + if (ALIPAY_PLATFORM.equals(record.getPayPlatformType())) { + Long amount = parseAlipayRoyaltyAmount(record.getFzRefundReq()); + return amount != null ? amount : parseAlipayRoyaltyAmount(record.getFzRefundRes()); + } + return null; + } + + /** + * 解析普通支付退款快照金额。 + * + * @param snapshot JSON 快照 + * @param platform 支付平台编码 + * @return 分金额;JSON 无法识别时为空 + */ + private Long parsePlatformAmount(String snapshot, String platform) { + if (StrUtil.isBlank(snapshot)) return null; + try { + JSONObject object = JSONUtil.parseObj(snapshot); + if (WECHAT_PLATFORM.equals(platform)) { + return parseFenCandidate(object, WECHAT_REFUND_KEYS); + } + if (ALIPAY_PLATFORM.equals(platform)) { + return parseYuanCandidate(object, ALIPAY_REFUND_KEYS); + } + } catch (Exception exception) { + log.debug("第三方支付统计退款快照解析失败,errorType:{}", exception.getClass().getSimpleName()); + } + return null; + } + + /** + * 解析微信退分账响应或请求中的分金额。 + * + * @param snapshot 微信退分账快照 + * @return 分金额;无法解析时为空 + */ + private Long parseWechatFzAmount(String snapshot) { + if (StrUtil.isBlank(snapshot)) return null; + try { + JSONObject object = JSONUtil.parseObj(snapshot); + Object response = findPath(object, "refund", "response"); + Long amount = parseFenObject(response); + if (amount != null) return amount; + return parseFenObject(findFirstByKey(object, "amount")); + } catch (Exception exception) { + log.debug("第三方支付统计微信退分账快照解析失败,errorType:{}", exception.getClass().getSimpleName()); + return null; + } + } + + /** + * 解析支付宝退分账明细中的元金额并精确转换为分。 + * + * @param snapshot 支付宝退分账请求或响应快照 + * @return 分金额;无法解析时为空 + */ + private Long parseAlipayRoyaltyAmount(String snapshot) { + if (StrUtil.isBlank(snapshot)) return null; + try { + Object root = JSONUtil.parse(snapshot); + List royaltyAmounts = new ArrayList<>(); + collectAmountsUnderKey(root, Set.of("refundRoyaltys", "refundRoyaltyParameters"), royaltyAmounts); + // 历史请求直接保存退款分账明细数组,不带 refundRoyaltyParameters 外层字段。 + if (royaltyAmounts.isEmpty() && root instanceof JSONArray) { + collectValuesByKey(root, "amount", royaltyAmounts); + } + if (royaltyAmounts.isEmpty()) return null; + long total = 0L; + for (Object value : royaltyAmounts) { + Long fen = yuanToFen(value); + if (fen == null) return null; + total = safeAdd(total, fen); + } + return total; + } catch (Exception exception) { + log.debug("第三方支付统计支付宝退分账快照解析失败,errorType:{}", exception.getClass().getSimpleName()); + return null; + } + } + + /** + * 按指定候选字段读取微信分金额。 + * + * @param object JSON 对象 + * @param keys 候选字段顺序 + * @return 分金额;无有效字段时为空 + */ + private Long parseFenCandidate(JSONObject object, Collection keys) { + for (String key : keys) { + Long amount = parseFenObject(findFirstByKey(object, key)); + if (amount != null) return amount; + } + return null; + } + + /** + * 按指定候选字段读取支付宝元金额。 + * + * @param object JSON 对象 + * @param keys 候选字段顺序 + * @return 分金额;无有效字段时为空 + */ + private Long parseYuanCandidate(JSONObject object, Collection keys) { + for (String key : keys) { + Long amount = yuanToFen(findFirstByKey(object, key)); + if (amount != null) return amount; + } + return yuanToFen(findFirstByKey(object, "amount")); + } + + /** + * 将微信候选对象(数字或含 refund 字段的 amount 对象)解析为分。 + * + * @param value JSON 节点 + * @return 分金额;无效或非正数时为空 + */ + private Long parseFenObject(Object value) { + if (value instanceof JSONObject object) { + Object refund = findFirstByKey(object, "refund"); + if (refund != null) return parseFenObject(refund); + Object amount = findFirstByKey(object, "amount"); + if (amount != null) return parseFenObject(amount); + } + return positiveLong(value); + } + + /** + * 把支付宝元金额用 BigDecimal 精确转换为分。 + * + * @param value 元金额节点 + * @return 分金额;格式错误时为空 + */ + private Long yuanToFen(Object value) { + if (value == null) return null; + try { + BigDecimal yuan = new BigDecimal(String.valueOf(value).trim()); + if (yuan.signum() <= 0) return null; + // 渠道金额只允许精确到分,出现更小单位时返回不可核对,不能静默四舍五入改变历史金额。 + return yuan.movePointRight(2).longValueExact(); + } catch (Exception exception) { + return null; + } + } + + /** + * 将微信分金额节点解析为正整数分。 + * + * @param value 分金额节点 + * @return 分金额;格式错误时为空 + */ + private Long positiveLong(Object value) { + if (value == null) return null; + try { + BigDecimal decimal = new BigDecimal(String.valueOf(value).trim()); + if (decimal.signum() <= 0 || decimal.stripTrailingZeros().scale() > 0) return null; + return decimal.longValueExact(); + } catch (Exception exception) { + return null; + } + } + + /** + * 按路径读取嵌套 JSON 节点。 + * + * @param root 根对象 + * @param path 路径字段 + * @return 节点;路径不存在时为空 + */ + private Object findPath(Object root, String... path) { + Object current = root; + for (String part : path) { + if (!(current instanceof JSONObject object)) return null; + current = object.get(part); + } + return current; + } + + /** + * 深度优先读取第一个指定字段,兼容微信 V2/V3 和渠道包装对象。 + * + * @param root JSON 节点 + * @param key 字段名 + * @return 第一个字段值 + */ + private Object findFirstByKey(Object root, String key) { + if (root instanceof JSONObject object) { + for (Map.Entry entry : object.entrySet()) { + if (entry.getKey().equalsIgnoreCase(key)) return entry.getValue(); + } + for (Object value : object.values()) { + Object result = findFirstByKey(value, key); + if (result != null) return result; + } + } else if (root instanceof JSONArray array) { + for (Object value : array) { + Object result = findFirstByKey(value, key); + if (result != null) return result; + } + } + return null; + } + + /** + * 收集指定字段下所有 amount 节点,用于多个支付宝退分账明细相加。 + * + * @param root JSON 节点 + * @param keys 需要展开的明细容器字段 + * @param result 金额节点输出集合 + */ + private void collectAmountsUnderKey(Object root, Set keys, List result) { + if (root instanceof JSONObject object) { + for (Map.Entry entry : object.entrySet()) { + if (keys.stream().anyMatch(key -> key.equalsIgnoreCase(entry.getKey()))) { + collectValuesByKey(entry.getValue(), "amount", result); + } + collectAmountsUnderKey(entry.getValue(), keys, result); + } + } else if (root instanceof JSONArray array) { + for (Object value : array) collectAmountsUnderKey(value, keys, result); + } + } + + /** + * 深度优先收集指定字段值。 + * + * @param root JSON 节点 + * @param key 字段名 + * @param result 值输出集合 + */ + private void collectValuesByKey(Object root, String key, List result) { + if (root instanceof JSONObject object) { + for (Map.Entry entry : object.entrySet()) { + if (entry.getKey().equalsIgnoreCase(key)) result.add(entry.getValue()); + collectValuesByKey(entry.getValue(), key, result); + } + } else if (root instanceof JSONArray array) { + for (Object value : array) collectValuesByKey(value, key, result); + } + } + + /** + * 将空数值转换为零,统一保护历史 NULL 聚合结果。 + * + * @param value 可能为空的数值 + * @return 非空数值或零 + */ + private long zeroIfNull(Long value) { + return value == null ? 0L : value; + } + + /** + * 对金额和次数执行溢出检查,避免 Long 溢出静默污染统计结果。 + * + * @param left 左值 + * @param right 右值 + * @return 精确和 + */ + private long safeAdd(long left, long right) { + return Math.addExact(left, right); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalAggregateRecord.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalAggregateRecord.java new file mode 100644 index 00000000..412d7d09 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalAggregateRecord.java @@ -0,0 +1,24 @@ +package com.bsd.pjcloud.thirdparty.statistics; + +import lombok.Data; + +/** + * SQL 聚合阶段的商户与支付方式金额结果。 + * + *

该对象只承载已经在数据库中按维度聚合的成功记录,金额单位为分,避免把大批成功订单完整加载到 JVM。

+ */ +@Data +public class ThirdPayTotalAggregateRecord { + + /** 商户主键,历史订单缺失时允许为空。 */ + private Long mchId; + + /** 支付平台类型编码,未知编码原样传递。 */ + private String payPlatformType; + + /** 当前聚合维度的记录数量。 */ + private Long recordCount; + + /** 当前聚合维度的金额,单位分。 */ + private Long amount; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalFzRefundRecord.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalFzRefundRecord.java new file mode 100644 index 00000000..a70257bb --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalFzRefundRecord.java @@ -0,0 +1,28 @@ +package com.bsd.pjcloud.thirdparty.statistics; + +import lombok.Data; + +/** + * 成功退分账记录及其历史快照。 + */ +@Data +public class ThirdPayTotalFzRefundRecord { + + /** 分账记录主键,用于明确每一条成功退分账事实。 */ + private Long id; + + /** 商户主键,来源于关联支付订单。 */ + private Long mchId; + + /** 支付平台类型编码,来源于关联支付订单。 */ + private String payPlatformType; + + /** 支付订单号,跨租户关联时与 tenant_id 一起作为连接条件。 */ + private String payNo; + + /** 微信或支付宝发起退分账请求快照。 */ + private String fzRefundReq; + + /** 微信或支付宝退分账响应快照。 */ + private String fzRefundRes; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalRefundRecord.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalRefundRecord.java new file mode 100644 index 00000000..ad3aa02c --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalRefundRecord.java @@ -0,0 +1,31 @@ +package com.bsd.pjcloud.thirdparty.statistics; + +import lombok.Data; + +/** + * 支付退款成功记录及其历史快照。 + */ +@Data +public class ThirdPayTotalRefundRecord { + + /** 支付订单主键,用于退款订单去重。 */ + private Long id; + + /** 商户主键。 */ + private Long mchId; + + /** 支付平台类型编码。 */ + private String payPlatformType; + + /** 支付订单号,便于诊断历史数据。 */ + private String payNo; + + /** 最终退款回调快照,金额解析优先使用。 */ + private String refundCallbackRes; + + /** 渠道退款响应快照,回调缺失时使用。 */ + private String refundRes; + + /** 发起退款请求快照,响应缺失时使用。 */ + private String refundRep; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalStatisticsSupport.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalStatisticsSupport.java new file mode 100644 index 00000000..197ce978 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalStatisticsSupport.java @@ -0,0 +1,22 @@ +package com.bsd.pjcloud.thirdparty.statistics; + +/** + * 统计维度键,使用商户和支付方式组成稳定的内存聚合键。 + * + * @param mchId 商户主键,历史订单未记录商户时允许为空 + * @param payPlatformType 支付平台类型编码,未知历史编码原样保留 + */ +public record ThirdPayTotalStatisticsSupport(Long mchId, String payPlatformType) { + + /** + * 创建统计维度键并规范空支付方式,确保数据库 NULL 与空字符串不会产生重复维度。 + * + * @param mchId 商户主键 + * @param payPlatformType 支付方式编码 + * @return 规范后的统计维度键 + */ + public static ThirdPayTotalStatisticsSupport of(Long mchId, String payPlatformType) { + return new ThirdPayTotalStatisticsSupport(mchId, payPlatformType == null || payPlatformType.isBlank() ? null : payPlatformType); + } + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPayTotalMapper.xml b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPayTotalMapper.xml new file mode 100644 index 00000000..e9622617 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPayTotalMapper.xml @@ -0,0 +1,98 @@ + + + + + + p.del_flag = '0' + and p.pay_time between #{query.payTimeList[0]} and #{query.payTimeList[1]} + + and p.mch_id = #{query.mchId} + + + and p.pay_platform_type = #{query.payPlatformType} + + + + + + + + + + + diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayTotalMapperSqlContractTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayTotalMapperSqlContractTest.java new file mode 100644 index 00000000..78770110 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayTotalMapperSqlContractTest.java @@ -0,0 +1,117 @@ +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; + +/** + * 第三方支付统计 SQL 契约测试,防止后续维护引入支付与分账扇出或移除租户连接条件。 + */ +class ThirdPayTotalMapperSqlContractTest { + + /** 第三方支付统计 MyBatis 映射资源路径。 */ + private static final String MAPPER_RESOURCE = "mapper/ThirdPayTotalMapper.xml"; + + /** 测试启动时读取的统计 Mapper XML。 */ + private static String mapperXml; + + /** + * 从测试类路径读取本轮实际参与编译的 Mapper XML。 + * + * @throws IOException 资源读取失败时抛出 + */ + @BeforeAll + static void loadMapperXml() throws IOException { + try (InputStream input = ThirdPayTotalMapperSqlContractTest.class.getClassLoader() + .getResourceAsStream(MAPPER_RESOURCE)) { + Assertions.assertNotNull(input, "第三方支付统计 Mapper XML 必须进入运行时类路径"); + mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } + + /** + * 支付成功聚合不得连接分账表,避免一个支付订单因多条分账记录重复累加金额。 + */ + @Test + void paySuccessAggregateShouldNotJoinProfitSharingRows() { + String sql = statement("selectPaySuccessAggregates"); + + Assertions.assertAll( + () -> Assertions.assertTrue(sql.contains("count(distinct p.id)")), + () -> Assertions.assertTrue(sql.contains("p.status = '20'")), + () -> Assertions.assertFalse(sql.contains("third_fz_order"))); + } + + /** + * 退款快照必须独立读取并在 Service 解析,不允许 SQL 展开 JSON 后制造重复退款事实。 + */ + @Test + void refundRecordsShouldRemainIndependentFromJsonExpansion() { + String sql = statement("selectRefundRecords"); + + Assertions.assertAll( + () -> Assertions.assertTrue(sql.contains("select distinct p.id")), + () -> Assertions.assertTrue(sql.contains("p.status = '200'")), + () -> Assertions.assertFalse(sql.toLowerCase().contains("json_extract")), + () -> Assertions.assertFalse(sql.toLowerCase().contains("json_table"))); + } + + /** + * 分账聚合必须先按租户和支付单号收敛,再通过相同二元键连接支付基础集合。 + */ + @Test + void profitSharingAggregateShouldJoinByTenantAndPayNoAfterGrouping() { + String sql = statement("selectFzSuccessAggregates"); + + Assertions.assertAll( + () -> Assertions.assertTrue(sql.contains("group by f.tenant_id, f.pay_no")), + () -> Assertions.assertTrue(sql.contains("fz.tenant_id = base.tenant_id")), + () -> Assertions.assertTrue(sql.contains("fz.pay_no = base.pay_no")), + () -> Assertions.assertTrue(sql.contains("f.status = '20'")), + () -> Assertions.assertTrue(sql.contains("f.del_flag = '0'"))); + } + + /** + * 成功退分账查询同样必须限制租户、支付单号、软删除和状态,不能跨租户串单。 + */ + @Test + void profitSharingRefundShouldKeepTenantAndSoftDeleteConditions() { + String sql = statement("selectFzRefundRecords"); + + Assertions.assertAll( + () -> Assertions.assertTrue(sql.contains("f.tenant_id = base.tenant_id")), + () -> Assertions.assertTrue(sql.contains("f.pay_no = base.pay_no")), + () -> Assertions.assertTrue(sql.contains("f.del_flag = '0'")), + () -> Assertions.assertTrue(sql.contains("f.status = '200'"))); + } + + /** + * 全部查询必须复用支付时间、商户、支付方式和支付订单软删除条件。 + */ + @Test + void baseWhereShouldKeepRequiredFilters() { + Assertions.assertAll( + () -> Assertions.assertTrue(mapperXml.contains("p.del_flag = '0'")), + () -> Assertions.assertTrue(mapperXml.contains("p.pay_time between #{query.payTimeList[0]} and #{query.payTimeList[1]}")), + () -> Assertions.assertTrue(mapperXml.contains("p.mch_id = #{query.mchId}")), + () -> Assertions.assertTrue(mapperXml.contains("p.pay_platform_type = #{query.payPlatformType}"))); + } + + /** + * 提取指定 ID 的 select 片段,兼容元素属性换行。 + * + * @param id MyBatis 查询 ID + * @return 对应查询 XML 片段 + */ + private static String statement(String id) { + int idIndex = mapperXml.indexOf("id=\"" + id + "\""); + int start = mapperXml.lastIndexOf("", idIndex); + Assertions.assertTrue(idIndex >= 0 && start >= 0 && end > idIndex, "缺少 MyBatis 查询:" + id); + return mapperXml.substring(start, end); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayTotalServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayTotalServiceImplTest.java new file mode 100644 index 00000000..bc291d7a --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayTotalServiceImplTest.java @@ -0,0 +1,254 @@ +package com.bsd.pjcloud.thirdparty.service.impl.pay; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayTotalQueryDto; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayTotalMapper; +import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalAggregateRecord; +import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalFzRefundRecord; +import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalRefundRecord; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalRowVo; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalStatisticsVo; +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.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.Mockito.when; + +/** + * 第三方支付统计服务测试,覆盖历史快照解析、范围校验、聚合完整性和商户服务降级。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdPayTotalServiceImplTest { + + /** 支付统计 Mapper Mock,分别返回支付、退款、分账和退分账事实。 */ + @Mock + private ThirdPayTotalMapper thirdPayTotalMapper; + + /** 商户远程服务 Mock,用于验证名称补充和异常降级。 */ + @Mock + private RemoteMchService remoteMchService; + + /** 被测第三方支付统计服务。 */ + @InjectMocks + private ThirdPayTotalServiceImpl service; + + /** + * 支付时间必须完整、正序且跨度不超过一年,防止空条件扫描全历史数据。 + */ + @Test + void validateQueryShouldRejectInvalidPayTimeRange() { + ThirdPayTotalQueryDto missing = new ThirdPayTotalQueryDto(); + ThirdPayTotalQueryDto incomplete = new ThirdPayTotalQueryDto(); + incomplete.setPayTimeList(new LocalDateTime[]{LocalDateTime.of(2026, 1, 1, 0, 0), null}); + ThirdPayTotalQueryDto reversed = query(LocalDateTime.of(2026, 2, 1, 0, 0), + LocalDateTime.of(2026, 1, 1, 0, 0)); + ThirdPayTotalQueryDto overOneYear = query(LocalDateTime.of(2025, 1, 1, 0, 0), + LocalDateTime.of(2026, 1, 1, 0, 0, 1)); + ThirdPayTotalQueryDto exactlyOneYear = query(LocalDateTime.of(2025, 1, 1, 0, 0), + LocalDateTime.of(2026, 1, 1, 0, 0)); + + Assertions.assertAll( + () -> Assertions.assertThrows(IllegalArgumentException.class, () -> service.validateQuery(null)), + () -> Assertions.assertThrows(IllegalArgumentException.class, () -> service.validateQuery(missing)), + () -> Assertions.assertThrows(IllegalArgumentException.class, () -> service.validateQuery(incomplete)), + () -> Assertions.assertThrows(IllegalArgumentException.class, () -> service.validateQuery(reversed)), + () -> Assertions.assertThrows(IllegalArgumentException.class, () -> service.validateQuery(overOneYear)), + () -> Assertions.assertDoesNotThrow(() -> service.validateQuery(exactlyOneYear))); + } + + /** + * 微信退款应兼容 V2 字段、V3 amount.refund,并严格遵循回调优先于响应和请求的顺序。 + */ + @Test + void parseRefundAmountShouldSupportWechatV2AndV3Snapshots() { + ThirdPayTotalRefundRecord v2 = refundRecord("10"); + v2.setRefundCallbackRes("{\"refund_fee\":\"125\"}"); + v2.setRefundRes("{\"refund_fee\":999}"); + ThirdPayTotalRefundRecord v3 = refundRecord("10"); + v3.setRefundRes("{\"amount\":{\"total\":1000,\"refund\":234}}"); + + Assertions.assertEquals(125L, service.parseRefundAmount(v2)); + Assertions.assertEquals(234L, service.parseRefundAmount(v3)); + } + + /** + * 支付宝退款金额按元使用 BigDecimal 精确转分,超过分精度或损坏快照必须标记为不可解析。 + */ + @Test + void parseRefundAmountShouldConvertAlipayYuanPrecisely() { + ThirdPayTotalRefundRecord precise = refundRecord("20"); + precise.setRefundCallbackRes("{\"refundAmount\":\"12.34\"}"); + ThirdPayTotalRefundRecord detail = refundRecord("20"); + detail.setRefundRes("{\"refund_detail_item_list\":[{\"amount\":\"0.07\"}]}"); + ThirdPayTotalRefundRecord subFen = refundRecord("20"); + subFen.setRefundRep("{\"refundAmount\":\"1.005\"}"); + ThirdPayTotalRefundRecord invalid = refundRecord("20"); + invalid.setRefundRep("not-json"); + + Assertions.assertAll( + () -> Assertions.assertEquals(1234L, service.parseRefundAmount(precise)), + () -> Assertions.assertEquals(7L, service.parseRefundAmount(detail)), + () -> Assertions.assertNull(service.parseRefundAmount(subFen)), + () -> Assertions.assertNull(service.parseRefundAmount(invalid))); + } + + /** + * 退分账解析应兼容微信结构化响应和支付宝多条分账退款明细,且不能读取普通整单退款金额。 + */ + @Test + void parseFzRefundAmountShouldReadOnlyRoyaltyRefundAmounts() { + ThirdPayTotalFzRefundRecord wechat = fzRefundRecord("10"); + wechat.setFzRefundRes("{\"refund\":{\"response\":{\"amount\":300}}}"); + ThirdPayTotalFzRefundRecord alipayRequest = fzRefundRecord("20"); + alipayRequest.setFzRefundReq("[{\"amount\":\"1.20\"},{\"amount\":\"0.03\"}]"); + ThirdPayTotalFzRefundRecord alipayResponse = fzRefundRecord("20"); + alipayResponse.setFzRefundRes("{\"response\":{\"refundRoyaltys\":[{\"amount\":\"2.50\"}]}}"); + ThirdPayTotalFzRefundRecord unrelatedAmount = fzRefundRecord("20"); + unrelatedAmount.setFzRefundRes("{\"refundAmount\":\"10.00\",\"amount\":\"10.00\"}"); + + Assertions.assertAll( + () -> Assertions.assertEquals(300L, service.parseFzRefundAmount(wechat)), + () -> Assertions.assertEquals(123L, service.parseFzRefundAmount(alipayRequest)), + () -> Assertions.assertEquals(250L, service.parseFzRefundAmount(alipayResponse)), + () -> Assertions.assertNull(service.parseFzRefundAmount(unrelatedAmount))); + } + + /** + * 四类事实应在商户与支付方式维度合并,未知退分账金额保留次数并使净收款标为部分可核对。 + */ + @Test + void statisticsShouldMergeIndependentFactsAndMarkIncompleteNetAmount() { + ThirdPayTotalQueryDto query = validQuery(); + when(thirdPayTotalMapper.selectPaySuccessAggregates(query)).thenReturn(List.of(aggregate(1L, "10", 2L, 1000L))); + ThirdPayTotalRefundRecord parsedRefund = refundRecord("10"); + parsedRefund.setMchId(1L); + parsedRefund.setRefundCallbackRes("{\"amount\":{\"refund\":200}}"); + ThirdPayTotalRefundRecord unknownRefund = refundRecord("10"); + unknownRefund.setMchId(1L); + when(thirdPayTotalMapper.selectRefundRecords(query)).thenReturn(List.of(parsedRefund, unknownRefund)); + when(thirdPayTotalMapper.selectFzSuccessAggregates(query)).thenReturn(List.of(aggregate(1L, "10", 2L, 300L))); + ThirdPayTotalFzRefundRecord parsedFzRefund = fzRefundRecord("10"); + parsedFzRefund.setMchId(1L); + parsedFzRefund.setFzRefundReq("{\"amount\":100}"); + ThirdPayTotalFzRefundRecord unknownFzRefund = fzRefundRecord("10"); + unknownFzRefund.setMchId(1L); + when(thirdPayTotalMapper.selectFzRefundRecords(query)).thenReturn(List.of(parsedFzRefund, unknownFzRefund)); + MchManageVo merchant = new MchManageVo(); + merchant.setId(1L); + merchant.setName("测试商户"); + when(remoteMchService.getByIds(List.of(1L))).thenReturn(R.ok(List.of(merchant))); + + ThirdPayTotalStatisticsVo result = service.statistics(query); + ThirdPayTotalRowVo row = result.getRows().get(0); + + Assertions.assertAll( + () -> Assertions.assertEquals("测试商户", row.getMchName()), + () -> Assertions.assertEquals(2L, row.getPayOrderCount()), + () -> Assertions.assertEquals(1000L, row.getPayAmount()), + () -> Assertions.assertEquals(2L, row.getRefundOrderCount()), + () -> Assertions.assertEquals(200L, row.getRefundAmount()), + () -> Assertions.assertEquals(1L, row.getRefundAmountUnavailableCount()), + () -> Assertions.assertEquals(2L, row.getFzSuccessCount()), + () -> Assertions.assertEquals(300L, row.getFzSuccessAmount()), + () -> Assertions.assertEquals(2L, row.getFzRefundSuccessCount()), + () -> Assertions.assertEquals(100L, row.getFzRefundSuccessAmount()), + () -> Assertions.assertEquals(1L, row.getFzRefundAmountUnavailableCount()), + () -> Assertions.assertEquals(200L, row.getPlatformNetAmount()), + () -> Assertions.assertFalse(row.isPlatformNetAmountComplete()), + () -> Assertions.assertEquals(row.getPlatformNetAmount(), result.getTotal().getPlatformNetAmount()), + () -> Assertions.assertFalse(result.getTotal().isPlatformNetAmountComplete())); + } + + /** + * 商户远程调用异常时应保留原商户 ID 和未知支付方式编码,避免历史统计行静默丢失。 + */ + @Test + void statisticsShouldKeepRowsWhenMerchantServiceFails() { + ThirdPayTotalQueryDto query = validQuery(); + when(thirdPayTotalMapper.selectPaySuccessAggregates(query)).thenReturn(List.of(aggregate(9L, "99", 1L, 500L))); + when(thirdPayTotalMapper.selectRefundRecords(query)).thenReturn(List.of()); + when(thirdPayTotalMapper.selectFzSuccessAggregates(query)).thenReturn(List.of()); + when(thirdPayTotalMapper.selectFzRefundRecords(query)).thenReturn(List.of()); + when(remoteMchService.getByIds(List.of(9L))).thenThrow(new IllegalStateException("remote unavailable")); + + ThirdPayTotalRowVo row = service.statistics(query).getRows().get(0); + + Assertions.assertAll( + () -> Assertions.assertEquals(9L, row.getMchId()), + () -> Assertions.assertEquals("商户信息不可用", row.getMchName()), + () -> Assertions.assertEquals("99", row.getPayPlatformType()), + () -> Assertions.assertEquals(500L, row.getPayAmount())); + } + + /** + * 构造合法的最近统计范围。 + * + * @return 包含完整时间范围的查询条件 + */ + private ThirdPayTotalQueryDto validQuery() { + return query(LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 31, 23, 59, 59)); + } + + /** + * 构造指定开始和结束时间的统计查询。 + * + * @param start 支付开始时间 + * @param end 支付结束时间 + * @return 支付统计查询条件 + */ + private ThirdPayTotalQueryDto query(LocalDateTime start, LocalDateTime end) { + ThirdPayTotalQueryDto query = new ThirdPayTotalQueryDto(); + query.setPayTimeList(new LocalDateTime[]{start, end}); + return query; + } + + /** + * 构造 SQL 聚合返回值。 + * + * @param mchId 商户主键 + * @param platform 支付平台编码 + * @param count 记录数 + * @param amount 金额,单位分 + * @return 聚合记录 + */ + private ThirdPayTotalAggregateRecord aggregate(Long mchId, String platform, Long count, Long amount) { + ThirdPayTotalAggregateRecord record = new ThirdPayTotalAggregateRecord(); + record.setMchId(mchId); + record.setPayPlatformType(platform); + record.setRecordCount(count); + record.setAmount(amount); + return record; + } + + /** + * 构造支付退款快照记录。 + * + * @param platform 支付平台编码 + * @return 退款记录 + */ + private ThirdPayTotalRefundRecord refundRecord(String platform) { + ThirdPayTotalRefundRecord record = new ThirdPayTotalRefundRecord(); + record.setPayPlatformType(platform); + return record; + } + + /** + * 构造退分账快照记录。 + * + * @param platform 支付平台编码 + * @return 退分账记录 + */ + private ThirdPayTotalFzRefundRecord fzRefundRecord(String platform) { + ThirdPayTotalFzRefundRecord record = new ThirdPayTotalFzRefundRecord(); + record.setPayPlatformType(platform); + return record; + } +} From fc35443b90059ff70351afb488aa8cb1c20d1341 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Wed, 26 Aug 2026 17:49:13 +0800 Subject: [PATCH 06/31] =?UTF-8?q?feat:=E9=92=B1=E5=8C=85=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E9=83=BD=E6=94=B9=E4=B8=BA=E5=88=86=E6=89=B9?= =?UTF-8?q?=EF=BC=8C=E5=89=8D=E7=AB=AF=E6=98=BE=E7=A4=BA=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E6=9D=A1=E7=9A=84=E6=A8=A1=E5=BC=8F=E3=80=82?= =?UTF-8?q?=20fix:=E4=BF=AE=E6=94=B9=E5=90=84=E4=B8=AA=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E7=BB=9F=E8=AE=A1=E7=9A=84=E6=95=B0=E9=87=8F?= =?UTF-8?q?=E4=B8=8D=E4=B8=80=E8=87=B4=E7=9A=84=E6=83=85=E5=86=B5=E3=80=82?= =?UTF-8?q?=20fix:=E4=BF=AE=E6=94=B9=E6=94=AF=E4=BB=98=E5=AE=9D=E5=88=86?= =?UTF-8?q?=E8=B4=A6=E9=80=80=E6=AC=BE=E5=A4=B1=E8=B4=A5=E7=9A=84=E6=83=85?= =?UTF-8?q?=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../order/HardCanteenUserApptQueryDto.java | 7 +- .../vo/order/HardCanteenUserApptOrderVo.java | 5 + .../api/vo/order/HardCanteenUserApptVo.java | 4 +- .../excel/HardCanteenUserApptExcelVo.java | 12 +- .../hard/mapper/order/HardCarMapper.java | 20 + .../model/HardCanteenReservationSaleFact.java | 39 + ...HardCanteenApptTotalExportServiceImpl.java | 151 +- .../HardCanteenApptTotalServiceImpl.java | 198 ++- .../resources/mapper/order/HardCarMapper.xml | 38 + .../ThirdAppPlusAliPayServiceImpl.java | 9 +- .../impl/appplus/ThirdAppPlusServiceImpl.java | 24 +- .../ThirdAppPlusAliPayServiceImplTest.java | 18 + ...irdAppPlusServiceImplOrderCodePayTest.java | 29 + .../vo/export/WalletManageExportExcelVo.java | 67 + .../WalletRechargeImportExportExcelVo.java | 71 + .../WalletRechargePersonExportExcelVo.java | 20 + .../WalletTransactionExportExcelVo.java | 71 + .../vo/export/WalletUserExportExcelVo.java | 54 + .../controller/WalletManageController.java | 10 +- .../WalletTransactionController.java | 168 ++- .../WalletRechargeImportController.java | 91 +- .../wallet/mapper/WalletManageMapper.java | 9 +- .../mapper/WalletTransactionMapper.java | 30 +- .../recharge/WalletRechargeImportMapper.java | 8 + .../wallet/service/WalletManageService.java | 23 + .../service/WalletRechargeImportService.java | 13 + .../service/WalletTransactionExportScene.java | 107 ++ .../service/WalletTransactionService.java | 25 + .../service/WalletUserExportService.java | 68 + .../service/impl/WalletManageServiceImpl.java | 72 +- .../impl/WalletTransactionServiceImpl.java | 57 +- .../export/WalletUserExportServiceImpl.java | 1249 +++++++++++++++++ .../WalletRechargeImportServiceImpl.java | 38 +- .../recharge/WalletRechargeImportMapper.xml | 2 +- 34 files changed, 2620 insertions(+), 187 deletions(-) create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardCanteenReservationSaleFact.java create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletManageExportExcelVo.java create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletRechargeImportExportExcelVo.java create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletRechargePersonExportExcelVo.java create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletTransactionExportExcelVo.java create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletUserExportExcelVo.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionExportScene.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletUserExportService.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/export/WalletUserExportServiceImpl.java 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 d16c4ae4..b0c91866 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 @@ -22,10 +22,13 @@ import java.util.List; @Schema(description = "食堂用户预约查询条件") public class HardCanteenUserApptQueryDto implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface { - /** 用餐日期闭区间,固定为开始、结束两个服务端本地日期,业务层限制跨度最多为两个自然月。 */ + /** + * 统计日期闭区间,固定为开始、结束两个服务端本地日期,业务层限制跨度最多为两个自然月。 + * 核销状态为 VERIFIED 时匹配实际核销时间,其他状态匹配预约用餐日期。 + */ @NotNull(message = "用餐日期范围不能为空") @Size(min = 2, max = 2, message = "用餐日期范围必须包含开始和结束日期") - @Schema(description = "用餐日期范围,固定为开始日期和结束日期", requiredMode = Schema.RequiredMode.REQUIRED) + @Schema(description = "统计日期范围,已核销取核销日期,其他状态取预约用餐日期", requiredMode = Schema.RequiredMode.REQUIRED) private LocalDate[] apptUseDateList; /** 用户姓名、登录账号或手机号的模糊查询关键字;为空时不限制。 */ 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 9e12c442..2f52cb63 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 @@ -3,6 +3,7 @@ package com.bsd.pjcloud.hard.api.vo.order; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import java.time.LocalDate; import java.time.LocalDateTime; /** @@ -20,6 +21,10 @@ public class HardCanteenUserApptOrderVo { @Schema(description = "用餐时间段字典编码") private String hourType; + /** 订单预约用餐日期;已核销按核销日查询时用于保留原预约日期,历史未记录时为空。 */ + @Schema(description = "预约用餐日期") + private LocalDate apptUseDate; + /** 下单时保存的商品描述;历史数据未记录时为空。 */ @Schema(description = "订餐内容") private String productDesc; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptVo.java index 032c03cc..50718668 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptVo.java @@ -92,8 +92,8 @@ public class HardCanteenUserApptVo implements BaseUserDeptInterface, BaseUserIde @Schema(description = "老师身份扩展信息") private SysUserTeacherDto teacherInfo; - /** 本行对应的用餐日期。 */ - @Schema(description = "用餐日期") + /** 本行统计日期;已核销筛选取实际核销日期,其他筛选取预约用餐日期。 */ + @Schema(description = "统计日期,已核销取核销日期,其他状态取预约用餐日期") private LocalDate apptUseDate; /** 订餐状态,RESERVED 表示当前筛选口径存在有效预约,NOT_RESERVED 表示不存在。 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenUserApptExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenUserApptExcelVo.java index c4cb50a6..74fabef3 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenUserApptExcelVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenUserApptExcelVo.java @@ -9,7 +9,7 @@ import java.math.BigDecimal; import java.time.LocalDate; /** - * 用户和用餐日期维度的食堂预约导出行。 + * 用户和统计日期维度的食堂预约导出行。 */ @Data @ColumnWidth(22) @@ -63,8 +63,8 @@ public class HardCanteenUserApptExcelVo { @ExcelProperty({"老师身份", "老师学号"}) private String teacherNo; - /** 查询指定的用餐日期。 */ - @ExcelProperty({"预约信息", "用餐日期"}) + /** 查询指定的统计日期;已核销筛选取核销日期,其他状态取预约用餐日期。 */ + @ExcelProperty({"预约信息", "统计日期"}) private LocalDate apptUseDate; /** 是否存在符合当前食堂、餐段筛选的有效预约。 */ @@ -103,12 +103,12 @@ public class HardCanteenUserApptExcelVo { @ExcelProperty({"金额统计", "餐段总金额(元)"}) private String mealSegmentTotalPrices; - /** 用户本行日期内有效预约商品总价,单位为元并保留两位小数。 */ - @ExcelProperty({"金额统计", "当日总金额(元)"}) + /** 用户本行统计日期内完成退款扣减后的预约商品净额,单位为元并保留两位小数。 */ + @ExcelProperty({"金额统计", "统计日净额(元)"}) @NumberFormat("#,##0.00") private BigDecimal totalPrice; - /** 用户当天有效预约的整体核销状态。 */ + /** 用户在本行统计日期和当前筛选口径内有效预约的整体核销状态。 */ @ExcelProperty({"核销信息", "核销状态"}) private String verificationStatus; 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 9a1e5485..26a42a9f 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 @@ -16,6 +16,7 @@ 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; +import com.bsd.pjcloud.hard.mapper.order.model.HardCanteenReservationSaleFact; import com.github.yulichang.wrapper.MPJLambdaWrapper; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -67,6 +68,14 @@ public interface HardCarMapper extends PjcloudBaseMapper { */ List productTotalByDeptStats(@Param("dto") HardOrderDto dto, DataScope dataScope); + /** + * 查询预约核销商品事实,复用菜品销售统计的核销时间和完成退款净额口径。 + * + * @param dto 核销日期、食堂、餐段和用户范围;调用方必须把来源限制为预约核销 + * @return 按核销时间、订单和商品稳定排序的有效商品事实,全额退款订单不返回记录 + */ + List listReservationVerifiedSaleFacts(@Param("dto") HardOrderDto dto); + Page productTotalByApptUseDate(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope); /** @@ -133,6 +142,17 @@ public interface HardCarMapper extends PjcloudBaseMapper { .orderByAsc(HardCarEntity::getId)); } + /** + * 按父购物车批量查询完成退款扣减后的预约商品快照。 + * + *

部分退款仅保留剩余份数和金额,全额退款商品不返回;该结果用于待核销、部分核销及预约日统计, + * 避免人员页面继续累计原始购物车快照。

+ * + * @param parentCarIds 预约订单关联的父购物车主键集合 + * @return 退款后仍有有效数量的商品快照;传入空集合时调用方应直接返回空列表 + */ + List listEffectiveReservationCars(@Param("parentCarIds") List parentCarIds); + /** * 菜品销量按照分类查询 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardCanteenReservationSaleFact.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardCanteenReservationSaleFact.java new file mode 100644 index 00000000..7ba31b46 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardCanteenReservationSaleFact.java @@ -0,0 +1,39 @@ +package com.bsd.pjcloud.hard.mapper.order.model; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 食堂预约核销商品事实。 + * + *

数据直接来自菜品销售统计的预约核销分支,日期取订单核销时间,数量和金额均已扣除状态为 50 的 + * 完成退款;仅用于 Hardware 服务内部统一人员统计与商品统计口径,不作为外部接口模型。

+ */ +@Data +public class HardCanteenReservationSaleFact { + + /** 订单实际核销完成时间,用于把人员统计行归入对应核销日期。 */ + private LocalDateTime statisticsTime; + + /** 已核销预约订单主键,用于订单去重及关联页面预约明细。 */ + private Long orderId; + + /** 实际用餐用户主键,用于人员筛选和人数去重。 */ + private Long userId; + + /** 订单餐段协议编码,来源于核销时的订单快照。 */ + private String hourType; + + /** 订单商品主键,历史数据无法识别商品时可为空。 */ + private Long productId; + + /** 下单时保存的商品名称,历史未记录时可为空。 */ + private String productName; + + /** 完成退款扣减后的有效商品份数,最小为零。 */ + private Integer productCount; + + /** 完成退款扣减后的有效商品金额,单位为分,最小为零。 */ + private Long totalPrice; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java index 9badde15..31123719 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java @@ -27,6 +27,7 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardSaleSourceEnum; 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; @@ -36,6 +37,7 @@ 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.mapper.order.model.HardCanteenReservationSaleFact; import com.bsd.pjcloud.hard.service.HardCanteenApptTotalExportService; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; @@ -499,15 +501,15 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot 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("用餐日期范围必须包含开始和结束日期"); + throw new IllegalArgumentException("统计日期范围必须包含开始和结束日期"); } LocalDate startDate = query.getApptUseDateList()[0]; LocalDate endDate = query.getApptUseDateList()[1]; if (endDate.isBefore(startDate)) { - throw new IllegalArgumentException("用餐结束日期不能早于开始日期"); + throw new IllegalArgumentException("统计结束日期不能早于开始日期"); } if (endDate.isAfter(startDate.plusMonths(MAX_QUERY_MONTHS))) { - throw new IllegalArgumentException("用餐日期范围最多查询两个月"); + throw new IllegalArgumentException("统计日期范围最多查询两个月"); } if (StrUtil.isNotBlank(query.getReservationStatus()) && !RESERVATION_STATUS_VALUES.contains(query.getReservationStatus())) { @@ -520,7 +522,7 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot } /** - * 展开查询范围内的全部自然日,开始和结束日期均包含在结果中。 + * 展开统计范围内的全部自然日,开始和结束日期均包含在结果中。 * * @param query 已通过日期范围校验的查询条件 * @return 按日期升序排列的查询日期 @@ -697,9 +699,9 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot } Map merchantNameMap = fetchMerchantNameMap(orders); Map>> orderMap = orders.stream() - .filter(order -> order.getUserId() != null && order.getApptUseDate() != null) + .filter(order -> order.getUserId() != null && resolveStatisticsDate(order, query) != null) .collect(Collectors.groupingBy(HardOrderVo::getUserId, LinkedHashMap::new, - Collectors.groupingBy(HardOrderVo::getApptUseDate, LinkedHashMap::new, + Collectors.groupingBy(order -> resolveStatisticsDate(order, query), LinkedHashMap::new, Collectors.mapping(order -> toOrderVo(order, merchantNameMap), Collectors.toList())))); return users.stream().filter(user -> user.getUserId() != null).flatMap(user -> queryDates.stream().map(date -> { @@ -754,16 +756,54 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot new ArrayList<>(userIds.subList(start, end))); orders.addAll(hardOrderMapper.scopeList(orderQuery, null)); } - return orders; + return retainEffectiveReservationOrders(orders, query); } /** - * 分批读取预约订单关联的购物车快照,避免统计请求因用户范围过大而生成超长 IN 条件。 + * 只保留完成退款后仍有有效商品的预约订单,并把导出金额改为退款后的净商品金额。 + * + * @param orders 已按订单状态和业务日期查询出的预约订单 + * @param query 当前导出筛选条件 + * @return 完成退款后仍有商品的订单,订单总价已更新为净额 + */ + private List retainEffectiveReservationOrders(List orders, + HardCanteenUserApptQueryDto query) { + if (orders.isEmpty()) { + return List.of(); + } + Map effectiveTotalPriceMap; + if (isVerifiedFilter(query)) { + Set orderIds = orders.stream().map(HardOrderVo::getId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + effectiveTotalPriceMap = fetchReservationVerifiedSaleFacts( + orders.stream().map(HardOrderVo::getUserId).filter(Objects::nonNull).distinct().toList(), query) + .stream().filter(fact -> fact.getOrderId() != null && orderIds.contains(fact.getOrderId())) + .collect(Collectors.groupingBy(HardCanteenReservationSaleFact::getOrderId, + Collectors.summingLong(fact -> number(fact.getTotalPrice())))); + } else { + Map> carMap = fetchEffectiveReservationCars(orders); + effectiveTotalPriceMap = orders.stream() + .filter(order -> order.getId() != null && order.getParentCarId() != null) + .filter(order -> carMap.containsKey(order.getParentCarId())) + .collect(Collectors.toMap(HardOrderVo::getId, + order -> carMap.get(order.getParentCarId()).stream() + .map(HardCarEntity::getTotalPrice) + .mapToLong(HardCanteenApptTotalExportServiceImpl::number).sum(), + (left, right) -> left, LinkedHashMap::new)); + } + return orders.stream().filter(order -> order.getId() != null) + .filter(order -> effectiveTotalPriceMap.containsKey(order.getId())) + .peek(order -> order.setTotalPrice(Math.toIntExact(effectiveTotalPriceMap.get(order.getId())))) + .toList(); + } + + /** + * 分批读取预约订单关联的完成退款后有效商品,避免生成超长父购物车 IN 条件。 * * @param orders 当前筛选口径下的预约订单 - * @return 父购物车主键到商品快照的映射 + * @return 父购物车主键到退款后有效商品的映射 */ - private Map> fetchReservationCars(List orders) { + private Map> fetchEffectiveReservationCars(List orders) { List parentCarIds = orders.stream().map(HardOrderVo::getParentCarId) .filter(Objects::nonNull).distinct().toList(); if (parentCarIds.isEmpty()) { @@ -772,12 +812,41 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot 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)) + hardCarMapper.listEffectiveReservationCars(parentCarIds.subList(start, end)) .forEach(car -> result.computeIfAbsent(car.getParentCarId(), key -> new ArrayList<>()).add(car)); } return result; } + /** + * 按用户分批查询菜品销售统计中的预约核销商品事实。 + * + * @param userIds 已经过 UPMS 数据权限和人员条件限制的用户主键 + * @param query 核销日期、食堂和餐段筛选条件 + * @return 完成退款扣减后仍有商品的预约核销事实 + */ + private List fetchReservationVerifiedSaleFacts( + List userIds, HardCanteenUserApptQueryDto query) { + if (userIds.isEmpty()) { + return List.of(); + } + List facts = 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 factQuery = new HardOrderDto(); + factQuery.setUserIds(new ArrayList<>(userIds.subList(start, end))); + factQuery.setMerchantId(query.getMerchantId()); + factQuery.setHourType(query.getHourType()); + factQuery.setSaleSourceCodes(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode())); + factQuery.setStatisticsDateTimeList(new LocalDateTime[]{ + query.getApptUseDateList()[0].atStartOfDay(), + query.getApptUseDateList()[1].atTime(LocalTime.MAX) + }); + facts.addAll(hardCarMapper.listReservationVerifiedSaleFacts(factQuery)); + } + return facts; + } + /** * 构造用户预约列表、候选用户预查询和导出明细共用的订单条件。 * @@ -795,15 +864,64 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot 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) - }); + LocalDateTime[] dateTimeRange = new LocalDateTime[]{ + query.getApptUseDateList()[0].atStartOfDay(), query.getApptUseDateList()[1].atTime(LocalTime.MAX)}; + if (isVerifiedFilter(query)) { + orderQuery.setOrderStatusCodes(List.of(HardOrderStatusEnum.CANCEL.getType())); + orderQuery.setVerificationTimeList(dateTimeRange); + } else { + orderQuery.setOrderStatusCodes(isUnverifiedFilter(query) + ? List.of(HardOrderStatusEnum.PAY.getType()) : VALID_RESERVATION_ORDER_STATUSES); + orderQuery.setApptUseDateTimeList(dateTimeRange); + } return orderQuery; } + /** + * 解析导出行的业务统计日期。 + * + * @param order 预约订单 + * @param query 当前核销状态筛选 + * @return 已核销筛选返回实际核销日期,其他情况返回预约用餐日期 + */ + private LocalDate resolveStatisticsDate(HardOrderVo order, HardCanteenUserApptQueryDto query) { + if (isVerifiedFilter(query)) { + return order.getVerificationTime() == null ? null : order.getVerificationTime().toLocalDate(); + } + return order.getApptUseDate(); + } + + /** + * 判断当前导出是否要求按单笔已核销订单统计。 + * + * @param query 导出查询条件 + * @return 核销状态为 VERIFIED 时返回 true + */ + private boolean isVerifiedFilter(HardCanteenUserApptQueryDto query) { + return Objects.equals(query.getVerificationStatus(), VERIFICATION_STATUS_VERIFIED); + } + + /** + * 判断当前导出是否要求按单笔待核销订单统计。 + * + * @param query 导出查询条件 + * @return 核销状态为 UNVERIFIED 时返回 true + */ + private boolean isUnverifiedFilter(HardCanteenUserApptQueryDto query) { + return Objects.equals(query.getVerificationStatus(), VERIFICATION_STATUS_UNVERIFIED); + } + + /** + * 将可能为空的商品金额安全归一为长整型零值。 + * + * @param value 商品金额,单位为分 + * @return 原数值的长整型表示;空值返回零 + */ + private static long number(Number value) { + return value == null ? 0L : value.longValue(); + } + /** * 批量补齐订单关联的食堂名称;商户服务异常时保留订单并返回空名称,避免影响预约事实查询。 * @@ -843,6 +961,7 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot HardCanteenUserApptOrderVo detail = new HardCanteenUserApptOrderVo(); detail.setId(order.getId()); detail.setHourType(order.getHourType()); + detail.setApptUseDate(order.getApptUseDate()); detail.setProductDesc(order.getProductDesc()); detail.setMerchantName(merchantNameMap.get(order.getMerchantId())); detail.setTotalPrice(order.getTotalPrice()); 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 99bb558c..194e5e93 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 @@ -13,6 +13,7 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardSaleSourceEnum; 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; @@ -27,6 +28,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsVo; import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; import com.bsd.pjcloud.hard.mapper.order.HardCarMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; +import com.bsd.pjcloud.hard.mapper.order.model.HardCanteenReservationSaleFact; import com.bsd.pjcloud.hard.service.HardCanteenApptTotalService; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; @@ -260,8 +262,8 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ /** * 按当前用户预约查询条件生成每日和查询范围总计。 * - *

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

+ *

统计先复用用户预约列表的权限用户、有效订单和状态筛选口径,再读取完成退款扣减后的有效商品。 + * 已核销筛选直接复用菜品销售统计的核销事实,保证人数、订单、份数和金额不会因整行状态或分页产生偏差。

* * @param query 用户、身份、日期范围、食堂、餐段和状态筛选条件 * @return 按日期升序排列并包含范围总计的统计结果 @@ -276,7 +278,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ 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> carMap = fetchReservationCars(orders, query); Map dailyBuckets = new LinkedHashMap<>(); queryDates.forEach(date -> dailyBuckets.put(date, new StatisticsBucket())); @@ -304,15 +306,15 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ 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("用餐日期范围必须包含开始和结束日期"); + throw new IllegalArgumentException("统计日期范围必须包含开始和结束日期"); } LocalDate startDate = query.getApptUseDateList()[0]; LocalDate endDate = query.getApptUseDateList()[1]; if (endDate.isBefore(startDate)) { - throw new IllegalArgumentException("用餐结束日期不能早于开始日期"); + throw new IllegalArgumentException("统计结束日期不能早于开始日期"); } if (endDate.isAfter(startDate.plusMonths(MAX_QUERY_MONTHS))) { - throw new IllegalArgumentException("用餐日期范围最多查询两个月"); + throw new IllegalArgumentException("统计日期范围最多查询两个月"); } if (StrUtil.isNotBlank(query.getReservationStatus()) && !RESERVATION_STATUS_VALUES.contains(query.getReservationStatus())) { @@ -325,7 +327,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ } /** - * 展开查询范围内的全部自然日,开始和结束日期均包含在结果中。 + * 展开统计范围内的全部自然日,开始和结束日期均包含在结果中。 * * @param query 已通过日期范围校验的查询条件 * @return 按日期升序排列的查询日期 @@ -621,9 +623,9 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ } Map merchantNameMap = fetchMerchantNameMap(orders); Map>> orderMap = orders.stream() - .filter(order -> order.getUserId() != null && order.getApptUseDate() != null) + .filter(order -> order.getUserId() != null && resolveStatisticsDate(order, query) != null) .collect(Collectors.groupingBy(HardOrderVo::getUserId, LinkedHashMap::new, - Collectors.groupingBy(HardOrderVo::getApptUseDate, LinkedHashMap::new, + Collectors.groupingBy(order -> resolveStatisticsDate(order, query), LinkedHashMap::new, Collectors.mapping(order -> toOrderVo(order, merchantNameMap), Collectors.toList())))); return users.stream().filter(user -> user.getUserId() != null).flatMap(user -> queryDates.stream().map(date -> { @@ -678,16 +680,78 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ new ArrayList<>(userIds.subList(start, end))); orders.addAll(hardOrderMapper.scopeList(orderQuery, null)); } - return orders; + return retainEffectiveReservationOrders(orders, query); } /** - * 分批读取预约订单关联的购物车快照,避免统计请求因用户范围过大而生成超长 IN 条件。 + * 只保留完成退款后仍有有效商品的预约订单,并把订单展示金额改为退款后的净商品金额。 + * + *

已核销筛选直接使用菜品销售统计事实判定有效订单;其他预约状态读取退款后有效商品快照。 + * 这样全额退款订单不会继续计入人员、订单和金额,部分退款只保留实际剩余值。

+ * + * @param orders 已按订单状态和业务日期查询出的预约订单 + * @param query 当前人员页面筛选条件 + * @return 完成退款后仍有商品的订单,订单总价已更新为净额 + */ + private List retainEffectiveReservationOrders(List orders, + HardCanteenUserApptQueryDto query) { + if (orders.isEmpty()) { + return List.of(); + } + Map effectiveTotalPriceMap; + if (isVerifiedFilter(query)) { + Set orderIds = orders.stream().map(HardOrderVo::getId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + effectiveTotalPriceMap = fetchReservationVerifiedSaleFacts( + orders.stream().map(HardOrderVo::getUserId).filter(Objects::nonNull).distinct().toList(), query) + .stream().filter(fact -> fact.getOrderId() != null && orderIds.contains(fact.getOrderId())) + .collect(Collectors.groupingBy(HardCanteenReservationSaleFact::getOrderId, + Collectors.summingLong(fact -> number(fact.getTotalPrice())))); + } else { + Map> carMap = fetchEffectiveReservationCars(orders); + effectiveTotalPriceMap = orders.stream() + .filter(order -> order.getId() != null && order.getParentCarId() != null) + .filter(order -> carMap.containsKey(order.getParentCarId())) + .collect(Collectors.toMap(HardOrderVo::getId, + order -> carMap.get(order.getParentCarId()).stream() + .map(HardCarEntity::getTotalPrice).mapToLong(HardCanteenApptTotalServiceImpl::number).sum(), + (left, right) -> left, LinkedHashMap::new)); + } + return orders.stream().filter(order -> order.getId() != null) + .filter(order -> effectiveTotalPriceMap.containsKey(order.getId())) + .peek(order -> order.setTotalPrice(Math.toIntExact(effectiveTotalPriceMap.get(order.getId())))) + .toList(); + } + + /** + * 分批读取预约订单关联的退款后有效商品,避免统计请求生成超长父购物车 IN 条件。 * * @param orders 当前筛选口径下的预约订单 - * @return 父购物车主键到商品快照的映射 + * @param query 当前人员页面筛选条件;已核销时用于切换到销售统计事实 + * @return 父购物车主键到有效商品快照的映射 */ - private Map> fetchReservationCars(List orders) { + private Map> fetchReservationCars(List orders, + HardCanteenUserApptQueryDto query) { + if (isVerifiedFilter(query)) { + Map orderMap = orders.stream().filter(order -> order.getId() != null) + .collect(Collectors.toMap(HardOrderVo::getId, Function.identity(), (left, right) -> left)); + return fetchReservationVerifiedSaleFacts( + orders.stream().map(HardOrderVo::getUserId).filter(Objects::nonNull).distinct().toList(), query) + .stream().filter(fact -> fact.getOrderId() != null && orderMap.containsKey(fact.getOrderId())) + .map(fact -> toEffectiveCar(fact, orderMap.get(fact.getOrderId()))) + .collect(Collectors.groupingBy(HardCarEntity::getParentCarId, LinkedHashMap::new, + Collectors.toList())); + } + return fetchEffectiveReservationCars(orders); + } + + /** + * 按父购物车分批读取完成退款扣减后的预约商品。 + * + * @param orders 当前筛选口径下的预约订单 + * @return 父购物车主键到退款后有效商品的映射 + */ + private Map> fetchEffectiveReservationCars(List orders) { List parentCarIds = orders.stream().map(HardOrderVo::getParentCarId) .filter(Objects::nonNull).distinct().toList(); if (parentCarIds.isEmpty()) { @@ -696,12 +760,58 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ 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)) + hardCarMapper.listEffectiveReservationCars(parentCarIds.subList(start, end)) .forEach(car -> result.computeIfAbsent(car.getParentCarId(), key -> new ArrayList<>()).add(car)); } return result; } + /** + * 按用户分批查询菜品销售统计中的预约核销商品事实。 + * + * @param userIds 已经过 UPMS 数据权限和人员条件限制的用户主键 + * @param query 核销日期、食堂和餐段筛选条件 + * @return 完成退款扣减后仍有商品的预约核销事实 + */ + private List fetchReservationVerifiedSaleFacts( + List userIds, HardCanteenUserApptQueryDto query) { + if (userIds.isEmpty()) { + return List.of(); + } + List facts = 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 factQuery = new HardOrderDto(); + factQuery.setUserIds(new ArrayList<>(userIds.subList(start, end))); + factQuery.setMerchantId(query.getMerchantId()); + factQuery.setHourType(query.getHourType()); + factQuery.setSaleSourceCodes(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode())); + factQuery.setStatisticsDateTimeList(new java.time.LocalDateTime[]{ + query.getApptUseDateList()[0].atStartOfDay(), + query.getApptUseDateList()[1].atTime(LocalTime.MAX) + }); + facts.addAll(hardCarMapper.listReservationVerifiedSaleFacts(factQuery)); + } + return facts; + } + + /** + * 将销售统计商品事实转换为人员统计聚合器使用的有效购物车快照。 + * + * @param fact 已扣完成退款的预约核销商品事实 + * @param order 商品所属预约订单 + * @return 仅包含统计所需父购物车、商品、份数和金额的内存对象 + */ + private HardCarEntity toEffectiveCar(HardCanteenReservationSaleFact fact, HardOrderVo order) { + HardCarEntity car = new HardCarEntity(); + car.setParentCarId(order.getParentCarId()); + car.setProductId(fact.getProductId()); + car.setProductName(fact.getProductName()); + car.setProductCount(fact.getProductCount()); + car.setTotalPrice(fact.getTotalPrice()); + return car; + } + /** * 构造用户预约列表、候选用户预查询和导出明细共用的订单条件。 * @@ -719,15 +829,64 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ 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) - }); + java.time.LocalDateTime[] dateTimeRange = new java.time.LocalDateTime[]{ + query.getApptUseDateList()[0].atStartOfDay(), query.getApptUseDateList()[1].atTime(LocalTime.MAX)}; + if (isVerifiedFilter(query)) { + orderQuery.setOrderStatusCodes(List.of(HardOrderStatusEnum.CANCEL.getType())); + orderQuery.setVerificationTimeList(dateTimeRange); + } else { + orderQuery.setOrderStatusCodes(isUnverifiedFilter(query) + ? List.of(HardOrderStatusEnum.PAY.getType()) : VALID_RESERVATION_ORDER_STATUSES); + orderQuery.setApptUseDateTimeList(dateTimeRange); + } return orderQuery; } + /** + * 解析当前订单在人员页面中的统计日期。 + * + * @param order 预约订单 + * @param query 当前核销状态筛选 + * @return 已核销筛选返回实际核销日期,其他情况返回预约用餐日期 + */ + private LocalDate resolveStatisticsDate(HardOrderVo order, HardCanteenUserApptQueryDto query) { + if (isVerifiedFilter(query)) { + return order.getVerificationTime() == null ? null : order.getVerificationTime().toLocalDate(); + } + return order.getApptUseDate(); + } + + /** + * 判断当前查询是否要求按单笔已核销订单统计。 + * + * @param query 人员页面查询条件 + * @return 核销状态为 VERIFIED 时返回 true + */ + private boolean isVerifiedFilter(HardCanteenUserApptQueryDto query) { + return Objects.equals(query.getVerificationStatus(), VERIFICATION_STATUS_VERIFIED); + } + + /** + * 判断当前查询是否要求按单笔待核销订单统计。 + * + * @param query 人员页面查询条件 + * @return 核销状态为 UNVERIFIED 时返回 true + */ + private boolean isUnverifiedFilter(HardCanteenUserApptQueryDto query) { + return Objects.equals(query.getVerificationStatus(), VERIFICATION_STATUS_UNVERIFIED); + } + + /** + * 将可能为空的数值安全归一为长整型零值。 + * + * @param value 商品数量或金额 + * @return 原数值的长整型表示;空值返回零 + */ + private static long number(Number value) { + return value == null ? 0L : value.longValue(); + } + /** * 批量补齐订单关联的食堂名称;商户服务异常时保留订单并返回空名称,避免影响预约事实查询。 * @@ -767,6 +926,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ HardCanteenUserApptOrderVo detail = new HardCanteenUserApptOrderVo(); detail.setId(order.getId()); detail.setHourType(order.getHourType()); + detail.setApptUseDate(order.getApptUseDate()); detail.setProductDesc(order.getProductDesc()); detail.setMerchantName(merchantNameMap.get(order.getMerchantId())); detail.setTotalPrice(order.getTotalPrice()); 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 f080f722..6c4483db 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 @@ -356,6 +356,44 @@ + + + + + + From 2c9feb5473acba6b310bee32b9e329a4d342dd37 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Wed, 26 Aug 2026 18:50:02 +0800 Subject: [PATCH 07/31] =?UTF-8?q?fix:=E6=94=AF=E4=BB=98=E5=AE=9D=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E5=88=86=E8=B4=A6=E5=85=B3=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ThirdMchFzRelationService.java | 3 ++- .../pay/ThirdMchFzRelationServiceImpl.java | 24 ++----------------- .../ThirdMchFzRelationServiceImplTest.java | 14 +++++++++++ 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java index b94029ad..96e19bcb 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java @@ -31,7 +31,8 @@ public interface ThirdMchFzRelationService { ThirdMchFzRelationVo saveForManage(ThirdMchFzRelationSaveDto dto); /** - * 删除关系;已绑定关系只标记为历史关系,默认关系删除前必须自动切换到可用替代关系。 + * 删除关系;已绑定关系只标记为历史关系,删除默认关系后允许当前支付配置暂时没有默认关系。 + * 新支付在新的默认关系绑定前会被支付前置校验拒绝,历史订单仍使用原关系快照。 * * @param ids 关系主键列表 * @return 全部处理成功时返回 true diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java index ced357d0..637de9ff 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java @@ -383,9 +383,8 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService /** * 删除关系;已绑定关系保留为历史事实,只移出默认候选,避免历史订单无法退分账。 * - *

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

+ *

删除默认关系不再强制寻找替代关系。删除后当前支付配置可以暂时没有默认关系, + * 新支付会由支付前置校验拒绝,直到管理端新增并绑定新的默认关系;历史订单仍按原关系快照继续退款和退分账。

* * @param ids 关系主键 * @return 是否全部处理成功 @@ -409,25 +408,6 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService if (relation == null) { continue; } - if (YesNoEnum.YES.getCode().equals(relation.getIsDefault())) { - ThirdMchFzRelationEntity replacement = relationMapper.selectOne(new LambdaQueryWrapper() - .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) - .eq(ThirdMchFzRelationEntity::getMchId, relation.getMchId()) - .eq(ThirdMchFzRelationEntity::getPaySettingId, relation.getPaySettingId()) - .eq(ThirdMchFzRelationEntity::getDelFlag, "0") - .eq(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BOUND.getCode()) - .eq(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.NO.getCode()) - .gt(ThirdMchFzRelationEntity::getFzRatio, 0D) - .notIn(ThirdMchFzRelationEntity::getId, relationIds) - .ne(ThirdMchFzRelationEntity::getId, relation.getId()) - .orderByDesc(ThirdMchFzRelationEntity::getCreateTime) - .last("LIMIT 1")); - if (replacement == null) { - throw new IllegalArgumentException("默认分账关系没有可用替代关系,请先新增并绑定一条启用抽佣的关系"); - } - // setDefault 会在当前事务内清理旧默认标记,再把候选关系设为默认。 - setDefault(replacement); - } LambdaUpdateWrapper update = new LambdaUpdateWrapper() .eq(ThirdMchFzRelationEntity::getId, id) .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java index 50df7458..63dbdad9 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java @@ -338,6 +338,20 @@ class ThirdMchFzRelationServiceImplTest { () -> Assertions.assertTrue(outRequestNo.matches("[a-z0-9_]+"))); } + /** + * 删除唯一默认关系时不再强制准备替代关系;已绑定关系转为历史状态,新支付由前置校验阻断。 + */ + @Test + void removeForManageShouldAllowDeletingDefaultWithoutReplacement() { + ThirdMchFzRelationEntity existing = boundWechatRelation(7010L); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(existing, null); + when(relationMapper.update(any(), any())).thenReturn(1); + + Assertions.assertTrue(service.removeForManage(List.of(existing.getId()))); + + verify(relationMapper, times(1)).update(any(), any()); + } + /** 构造商户关系同步快照。 */ private MchManageVo merchant(List settings) { MchManageVo merchant = new MchManageVo(); From 0c5d21b70b47b6c8254e9a05317e801a762fafc0 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Wed, 26 Aug 2026 19:06:55 +0800 Subject: [PATCH 08/31] =?UTF-8?q?fix:"=E6=9F=A5=E8=AF=A2=E8=8F=9C=E5=93=81?= =?UTF-8?q?"=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java | 3 +++ 1 file changed, 3 insertions(+) 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 d5699377..37a48164 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 @@ -52,6 +52,9 @@ public class HardCarDto extends HardCarEntity implements BaseIsIncludeTreeInterf private List userDeptIds; + /** 用户主键筛选集合;为空表示不限制用户范围,供菜品统计及明细查询复用。 */ + private List userIds; + private Boolean isInclude; } From 19596d9819cc6ce3836c8c805939124dc5f8af63 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Wed, 26 Aug 2026 19:58:12 +0800 Subject: [PATCH 09/31] =?UTF-8?q?feat:=E9=92=B1=E5=8C=85=E5=BC=80=E5=8D=A1?= =?UTF-8?q?=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wallet/api/dto/WalletActiveDto.java | 6 +- .../vo/export/WalletActiveExportExcelVo.java | 50 +++++++ .../controller/WalletActiveController.java | 30 +++- .../wallet/mapper/WalletActiveMapper.java | 14 +- .../wallet/service/WalletActiveService.java | 16 +++ .../service/WalletUserExportService.java | 17 +++ .../service/impl/WalletActiveServiceImpl.java | 32 ++++- .../export/WalletUserExportServiceImpl.java | 133 ++++++++++++++++++ 8 files changed, 290 insertions(+), 8 deletions(-) create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletActiveExportExcelVo.java diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletActiveDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletActiveDto.java index b947f13e..87231e48 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletActiveDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletActiveDto.java @@ -25,12 +25,16 @@ public class WalletActiveDto extends WalletActiveEntity implements BaseIsInclude @NotNull(message = "商户id不能为空") private Long merchantId; - private Long merchantIdList; + /** 页面多选的商户主键集合;为空时不限制开卡记录所属商户。 */ + private List merchantIdList; private Long walletTypeId; private List userIds; + /** 勾选导出的开卡记录主键集合;为空时按页面其余筛选条件导出全部匹配记录。 */ + private List ids; + /** UPMS 用户身份协议编码集合;为空时不限制身份。 */ private List identityTypes; diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletActiveExportExcelVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletActiveExportExcelVo.java new file mode 100644 index 00000000..ef5088c8 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletActiveExportExcelVo.java @@ -0,0 +1,50 @@ +package com.bsd.pjcloud.wallet.api.vo.export; + +import com.alibaba.excel.annotation.ExcelProperty; +import com.alibaba.excel.annotation.write.style.ColumnWidth; +import com.alibaba.excel.annotation.write.style.ContentFontStyle; +import com.alibaba.excel.annotation.write.style.HeadFontStyle; +import com.alibaba.excel.annotation.write.style.HeadRowHeight; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 钱包开卡记录导出行,按用户、身份、商户和饭卡组织中文二级表头。 + * + *

用户与身份资料由 UPMS 完整导出接口按分片补齐;未绑定用户的空闲饭卡保留空白用户列, + * 业务列仍输出商户、卡号、绑定和硬件激活状态,便于开卡人员核对库存。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +@ColumnWidth(15) +@HeadRowHeight(15) +@HeadFontStyle(fontHeightInPoints = 10) +@ContentFontStyle(fontHeightInPoints = 10) +public class WalletActiveExportExcelVo extends WalletUserExportExcelVo { + + /** 当前开卡记录在本次导出结果中的一基序号。 */ + @ExcelProperty(value = {"开卡", "序号"}, index = 10) + private String lineNumber; + + /** 开卡记录所属商户名称;商户资料已经删除或无法匹配时为空。 */ + @ExcelProperty(value = {"商户", "所属商户"}, index = 11) + private String merchantName; + + /** 开卡记录保存的饭卡卡号。 */ + @ExcelProperty(value = {"饭卡", "用户卡号"}, index = 12) + @ColumnWidth(22) + private String cardNumber; + + /** 饭卡是否同时关联用户和用户钱包,使用“是/否”展示。 */ + @ExcelProperty(value = {"饭卡", "用户绑定状态"}, index = 13) + private String bindStatus; + + /** 饭卡是否已经在硬件设备完成激活,未知历史编码保留原值。 */ + @ExcelProperty(value = {"饭卡", "硬件激活状态"}, index = 14) + private String hardwareActiveStatus; + + /** 开卡记录创建时间,格式为 yyyy-MM-dd HH:mm:ss。 */ + @ExcelProperty(value = {"开卡", "创建时间"}, index = 15) + @ColumnWidth(22) + private String createTime; +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java index e977cb01..985e44d5 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java @@ -2,7 +2,6 @@ package com.bsd.pjcloud.wallet.controller; import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes; -import cn.hutool.core.util.ArrayUtil; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.ObjectUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; @@ -14,6 +13,7 @@ import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.core.validation.Group; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; import com.bsd.pjcloud.common.excel.annotation.RequestExcel; import com.bsd.pjcloud.common.idempotent.annotation.Idempotent; import com.bsd.pjcloud.common.log.annotation.SysLog; @@ -27,9 +27,10 @@ import com.bsd.pjcloud.wallet.api.vo.WalletActiveVo; import com.bsd.pjcloud.wallet.service.MchManageService; import com.bsd.pjcloud.wallet.service.WalletActiveService; import com.bsd.pjcloud.wallet.service.WalletActiveImportService; +import com.bsd.pjcloud.wallet.service.WalletUserExportService; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import org.springframework.security.access.prepost.PreAuthorize; -import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.HttpHeaders; @@ -59,6 +60,8 @@ public class WalletActiveController { private final WalletActiveService walletActiveService; /** 钱包开卡 Excel 导入服务,隔离默认和学生模板的校验及异步编排。 */ private final WalletActiveImportService walletActiveImportService; + /** 钱包用户导出服务,负责开卡记录分片查询、中文二级表头和异步进度。 */ + private final WalletUserExportService walletUserExportService; private final MchManageService mchManageService; private final RemoteDeptService remoteDeptService; @@ -227,11 +230,28 @@ public class WalletActiveController { return R.ok(walletActiveService.removeWithWalletCardByIds(CollUtil.toList(ids))); } - @ResponseExcel + /** + * 使用中文二级表头同步分片导出开卡记录,保留旧 GET 地址供既有调用方兼容。 + * + * @param response 当前 HTTP 下载响应 + * @param dto 页面当前开卡、用户身份、部门和勾选记录筛选条件 + */ @GetMapping("/export") @PreAuthorize("@pms.hasPermission('wallet_walletActive_export')") - public List export(WalletActiveEntity walletActive, Long[] ids) { - return walletActiveService.list(Wrappers.lambdaQuery(walletActive).in(ArrayUtil.isNotEmpty(ids), WalletActiveEntity::getId, ids)); + public void export(HttpServletResponse response, @ParameterObject WalletActiveDto dto) { + walletUserExportService.exportWalletActive(response, dto); + } + + /** + * 提交钱包开卡记录异步分片导出任务,由后台补齐用户身份并实时上报生成进度。 + * + * @param dto 页面当前开卡、用户身份、部门和勾选记录筛选条件 + * @return 包含任务编号和初始进度的统一响应 + */ + @PostMapping("/export-async") + @PreAuthorize("@pms.hasPermission('wallet_walletActive_export')") + public R submitExport(@RequestBody WalletActiveDto dto) { + return R.ok(walletUserExportService.submitWalletActiveExport(dto), "钱包开卡导出任务已提交"); } /** diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java index d80de894..fe176d9b 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java @@ -29,6 +29,16 @@ public interface WalletActiveMapper extends PjcloudBaseMapper查询应用卡号、激活状态、部门、用户、商户、勾选主键及包含下级部门等筛选条件, + * 并按饭卡顺序、记录主键倒序返回稳定分页;未绑定及硬件激活条件沿用现有 1/0 业务语义。

+ * + * @param page 页码、每页数量及是否统计总数的分页参数 + * @param dto 开卡页面或导出任务冻结后的筛选条件 + * @return 当前数据权限范围内的开卡记录分页,记录转换为 {@link WalletActiveVo} + */ default Page scopePageList(Page page, WalletActiveDto dto) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .like(ObjectUtil.isNotEmpty(dto.getCardNumber()), WalletActiveEntity::getCardNumber, dto.getCardNumber()) @@ -36,7 +46,9 @@ public interface WalletActiveMapper extends PjcloudBaseMapper { Page scopePageList(Page page, WalletActiveDto dto); + /** + * 按数据权限分页读取开卡导出记录,仅补齐商户资料,用户与身份由统一导出服务按分片处理。 + * + * @param page 导出页码、分片大小和是否统计总数的分页参数 + * @param dto 已冻结身份及勾选记录范围的开卡筛选条件 + * @return 保持稳定卡序和主键顺序的开卡导出分页 + */ + Page scopeExportPageList(Page page, WalletActiveDto dto); + + /** + * 将身份筛选条件冻结为开卡表可直接使用的用户主键集合。 + * + * @param dto 开卡页面筛选条件;方法会清空身份扩展条件并保留最终用户主键范围 + */ + void prepareIdentityQuery(WalletActiveDto dto); + /** * 远程调用开卡并且绑定用户的列表 */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletUserExportService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletUserExportService.java index 3b33dd65..f6e862ef 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletUserExportService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletUserExportService.java @@ -1,6 +1,7 @@ package com.bsd.pjcloud.wallet.service; import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; +import com.bsd.pjcloud.wallet.api.dto.WalletActiveDto; import com.bsd.pjcloud.wallet.api.dto.WalletManageBalanceDto; import com.bsd.pjcloud.wallet.api.dto.WalletManageDto; import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto; @@ -15,6 +16,22 @@ import jakarta.servlet.http.HttpServletResponse; */ public interface WalletUserExportService { + /** + * 提交钱包开卡记录异步分片导出任务。 + * + * @param dto 页面当前开卡、用户身份、部门和勾选记录筛选条件 + * @return 供前端轮询的初始任务进度 + */ + AsyncTaskProgressVO submitWalletActiveExport(WalletActiveDto dto); + + /** + * 使用中文二级表头同步分片导出钱包开卡记录,供旧调用方兼容使用。 + * + * @param response 当前 HTTP 下载响应 + * @param dto 页面当前开卡、用户身份、部门和勾选记录筛选条件 + */ + void exportWalletActive(HttpServletResponse response, WalletActiveDto dto); + /** * 提交钱包流水异步分片导出任务。 * diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java index 913b24cd..1a27bdda 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java @@ -360,12 +360,42 @@ public class WalletActiveServiceImpl extends ServiceImpl导出用户和身份资料由 {@code WalletUserExportServiceImpl} 使用完整用户接口统一补齐, + * 此处不再调用普通列表用户接口,避免每个大数据分片重复远程查询同一批用户。

+ * + * @param page 导出页码、分片大小和是否统计总数的分页参数 + * @param dto 已冻结身份及勾选记录范围的开卡筛选条件 + * @return 已补齐商户名称的开卡导出分页 + */ + @Override + public Page scopeExportPageList(Page page, WalletActiveDto dto) { + prepareIdentityQuery(dto); + Page list = baseMapper.scopePageList(page, dto); + if (ObjectUtil.isEmpty(list.getRecords())) { + return list; + } + List merchantIds = list.getRecords().stream().map(WalletActiveVo::getMerchantId) + .filter(Objects::nonNull).distinct().toList(); + if (ObjectUtil.isNotEmpty(merchantIds)) { + List merchants = mchManageService.list(MchManageDto.builder().ids(merchantIds).build()); + if (ObjectUtil.isNotEmpty(merchants)) { + WalletDataUtil.convertMchList(list.getRecords(), + CollectionUtils.convertMap(merchants, MchManageVo::getId)); + } + } + return list; + } + /** * 将身份筛选转换为开卡记录可直接使用的用户主键条件。 * * @param dto 开卡记录查询条件;处理后保留最终用户主键集合 */ - private void prepareIdentityQuery(WalletActiveDto dto) { + @Override + public void prepareIdentityQuery(WalletActiveDto dto) { if (!userIdentitySupport.hasIdentityQuery(dto)) { return; } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/export/WalletUserExportServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/export/WalletUserExportServiceImpl.java index 949563d6..a1a6dc03 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/export/WalletUserExportServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/export/WalletUserExportServiceImpl.java @@ -23,12 +23,15 @@ import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate; import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum; import com.bsd.pjcloud.wallet.api.constant.WalletBizRechargeEnum; +import com.bsd.pjcloud.wallet.api.dto.WalletActiveDto; import com.bsd.pjcloud.wallet.api.dto.WalletManageBalanceDto; import com.bsd.pjcloud.wallet.api.dto.WalletManageDto; import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto; import com.bsd.pjcloud.wallet.api.dto.mch.WalletMchDeptViewDto; import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeImportDto; +import com.bsd.pjcloud.wallet.api.vo.WalletActiveVo; import com.bsd.pjcloud.wallet.api.vo.WalletManageVo; +import com.bsd.pjcloud.wallet.api.vo.export.WalletActiveExportExcelVo; import com.bsd.pjcloud.wallet.api.vo.export.WalletManageExportExcelVo; import com.bsd.pjcloud.wallet.api.vo.export.WalletRechargeImportExportExcelVo; import com.bsd.pjcloud.wallet.api.vo.export.WalletRechargePersonExportExcelVo; @@ -38,6 +41,7 @@ import com.bsd.pjcloud.wallet.api.vo.mch.WalletMchDeptViewVo; import com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeImportVo; import com.bsd.pjcloud.wallet.api.vo.transaction.WalletTransactionVo; import com.bsd.pjcloud.wallet.mapper.mch.view.MchDeptViewMapper; +import com.bsd.pjcloud.wallet.service.WalletActiveService; import com.bsd.pjcloud.wallet.service.WalletManageService; import com.bsd.pjcloud.wallet.service.WalletRechargeImportService; import com.bsd.pjcloud.wallet.service.WalletTransactionExportScene; @@ -105,6 +109,9 @@ public class WalletUserExportServiceImpl implements WalletUserExportService { /** 账户当前余额导出任务的 Redis Key 业务命名空间。 */ private static final String BALANCE_EXPORT_REDIS_KEY = "wallet:manage:export:balance"; + /** 钱包开卡记录导出任务的 Redis Key 业务命名空间。 */ + private static final String ACTIVE_EXPORT_REDIS_KEY = "wallet:user:export:active"; + /** 月度钱包记录导出任务的 Redis Key 业务命名空间。 */ private static final String RECORD_EXPORT_REDIS_KEY = "wallet:manage:export:record"; @@ -114,6 +121,9 @@ public class WalletUserExportServiceImpl implements WalletUserExportService { /** 账户当前余额导出任务展示名称。 */ private static final String BALANCE_EXPORT_TASK_NAME = "账户余额导出"; + /** 钱包开卡记录导出任务展示名称。 */ + private static final String ACTIVE_EXPORT_TASK_NAME = "钱包开卡导出"; + /** 月度钱包记录导出任务展示名称。 */ private static final String RECORD_EXPORT_TASK_NAME = "钱包记录导出"; @@ -158,6 +168,9 @@ public class WalletUserExportServiceImpl implements WalletUserExportService { /** 钱包流水服务,负责分页数据权限、钱包类型和历史用户展示资料装配。 */ private final WalletTransactionService walletTransactionService; + /** 钱包开卡服务,负责按数据权限分页读取开卡、商户和历史用户展示资料。 */ + private final WalletActiveService walletActiveService; + /** 钱包管理服务,负责当前余额和月度历史余额分页计算。 */ private final WalletManageService walletManageService; @@ -176,6 +189,43 @@ public class WalletUserExportServiceImpl implements WalletUserExportService { /** 通用异步任务执行器,负责恢复租户、安全和 Feign 上下文并持久化进度。 */ private final AsyncTaskExecutorService asyncTaskExecutorService; + /** + * 提交钱包开卡记录异步分片导出任务。 + * + * @param dto 页面当前开卡、身份、部门和勾选记录筛选条件 + * @return 供前端轮询的初始任务进度 + */ + @Override + public AsyncTaskProgressVO submitWalletActiveExport(WalletActiveDto dto) { + WalletActiveDto querySnapshot = copyWalletActiveQuery(dto); + walletActiveService.prepareIdentityQuery(querySnapshot); + return asyncTaskExecutorService.submitWithResult(ACTIVE_EXPORT_REDIS_KEY, ACTIVE_EXPORT_TASK_NAME, + INITIAL_PROGRESS_TOTAL, reporter -> exportWalletActiveRecords(querySnapshot, reporter)); + } + + /** + * 使用中文二级表头同步分片导出钱包开卡记录,保留旧 GET 接口兼容性。 + * + * @param response 当前 HTTP 下载响应 + * @param dto 页面当前开卡、身份、部门和勾选记录筛选条件 + */ + @Override + public void exportWalletActive(HttpServletResponse response, WalletActiveDto dto) { + WalletActiveDto querySnapshot = copyWalletActiveQuery(dto); + walletActiveService.prepareIdentityQuery(querySnapshot); + Page firstPage = fetchWalletActivePage(1L, querySnapshot, true); + long total = validateTotal(firstPage.getTotal(), ACTIVE_EXPORT_TASK_NAME); + response.setContentType(EXCEL_CONTENT_TYPE); + writePagedWorkbook(firstPage, total, null, WalletActiveExportExcelVo.class, + "钱包开卡", "wallet-active-export-", + current -> fetchWalletActivePage(current, querySnapshot, false), this::convertWalletActiveRows, + file -> { + Files.copy(file, response.getOutputStream()); + response.flushBuffer(); + return null; + }); + } + /** * 提交钱包流水异步分片导出任务。 * @@ -416,6 +466,27 @@ public class WalletUserExportServiceImpl implements WalletUserExportService { return AsyncTaskResult.file(fileUrl, fileName); } + /** + * 在异步线程中生成并上传钱包开卡记录工作簿。 + * + * @param query 已冻结身份和勾选记录范围的开卡查询 + * @param reporter 当前任务进度报告器 + * @return 文件中心下载地址和业务文件名 + */ + private AsyncTaskResult exportWalletActiveRecords(WalletActiveDto query, + AsyncTaskProgressReporter reporter) { + Page firstPage = fetchWalletActivePage(1L, query, true); + long total = validateTotal(firstPage.getTotal(), ACTIVE_EXPORT_TASK_NAME); + updateProgressTotal(reporter, total, ACTIVE_EXPORT_TASK_NAME); + String fileName = buildFileName("钱包开卡"); + String fileUrl = writePagedWorkbook(firstPage, total, reporter, WalletActiveExportExcelVo.class, + "钱包开卡", "wallet-active-export-", + current -> fetchWalletActivePage(current, query, false), this::convertWalletActiveRows, + file -> uploadExportFile(file, fileName)); + reporter.advance("钱包开卡导出文件已上传,共 " + total + " 条"); + return AsyncTaskResult.file(fileUrl, fileName); + } + /** * 使用 EasyExcel 将分页数据流式写入二级表头工作簿,并把完整临时文件交给调用方消费。 * @@ -493,6 +564,19 @@ public class WalletUserExportServiceImpl implements WalletUserExportService { new Page<>(current, EXPORT_BATCH_SIZE, searchCount), query, DataScope.of()); } + /** + * 分页查询钱包开卡记录,并控制只有第一页执行总数统计。 + * + * @param current 当前一基页码 + * @param query 已冻结身份和勾选记录范围的开卡查询 + * @param searchCount 是否查询符合条件的总记录数 + * @return 已补齐用户、身份和商户资料的开卡记录分页 + */ + private Page fetchWalletActivePage(long current, WalletActiveDto query, + boolean searchCount) { + return walletActiveService.scopeExportPageList(new Page<>(current, EXPORT_BATCH_SIZE, searchCount), query); + } + /** * 分页查询个人充值统计流水,并在数据库查询阶段应用固定排除部门规则。 * @@ -570,6 +654,33 @@ public class WalletUserExportServiceImpl implements WalletUserExportService { return rows; } + /** + * 将一个钱包开卡记录分片转换为中文二级表头导出行。 + * + * @param records 当前开卡记录分片 + * @param startIndex 当前分片前已经写入的记录数 + * @return 与输入顺序一致的开卡导出行 + */ + private List convertWalletActiveRows(List records, + long startIndex) { + Map userMap = fetchExportUserMap(extractUserIds(records.stream() + .map(WalletActiveVo::getUserId).toList())); + List rows = new ArrayList<>(records.size()); + for (WalletActiveVo source : records) { + WalletActiveExportExcelVo row = new WalletActiveExportExcelVo(); + fillUserColumns(row, userMap.get(source.getUserId()), source.getUsername(), source.getPhone(), + source.getDeptName(), source.getIdentityTypes(), source.getStudentInfo(), source.getTeacherInfo()); + row.setLineNumber(Long.toString(startIndex + rows.size() + 1L)); + row.setMerchantName(source.getMerchantName()); + row.setCardNumber(source.getCardNumber()); + row.setBindStatus(resolveYesNo(source.getUserId() != null && source.getWalletId() != null ? "1" : "0")); + row.setHardwareActiveStatus(resolveYesNo(source.getIsActiveHardware())); + row.setCreateTime(formatDateTime(source.getCreateTime())); + rows.add(row); + } + return rows; + } + /** * 将一个个人充值统计分片转换为带所属食堂的二级表头导出行。 * @@ -1107,6 +1218,28 @@ public class WalletUserExportServiceImpl implements WalletUserExportService { return target; } + /** + * 复制钱包开卡查询及其可变筛选字段。 + * + * @param source Controller 接收的原始开卡查询对象 + * @return 后台任务独占的查询快照 + */ + private WalletActiveDto copyWalletActiveQuery(WalletActiveDto source) { + WalletActiveDto target = new WalletActiveDto(); + if (source == null) { + return target; + } + BeanUtils.copyProperties(source, target); + target.setIds(copyList(source.getIds())); + target.setUserIds(copyList(source.getUserIds())); + target.setMerchantIdList(copyList(source.getMerchantIdList())); + target.setDeptIds(copyList(source.getDeptIds())); + target.setIdentityTypes(copyList(source.getIdentityTypes())); + target.setStudentInfo(copyStudentInfo(source.getStudentInfo())); + target.setTeacherInfo(copyTeacherInfo(source.getTeacherInfo())); + return target; + } + /** * 复制账户余额查询及其可变筛选字段。 * From 440cffb3d55a3c60adf4a1745ed118162dd693e1 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 27 Aug 2026 14:00:36 +0800 Subject: [PATCH 10/31] =?UTF-8?q?feat:=E5=A2=9E=E5=8A=A0=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=E8=AE=A2=E9=A4=90=E7=9A=84"=E9=94=80=E5=94=AE?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1"=E5=92=8C"=E9=A2=84=E7=BA=A6"=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=20feat:=E4=BC=98=E5=8C=96=E4=BA=86=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=A1=B5=E9=9D=A2=20feat:=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=BA=86=E7=89=88=E6=9C=AC=E6=9B=B4=E6=96=B0=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=20feat:=E6=94=AF=E4=BB=98=E5=88=86=E8=B4=A6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=B8=BA=E5=8F=AF=E4=B8=8D=E5=88=86=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...27_add_third_mch_fz_relation_enable_fz.sql | 3 + .../refund/HardAfterSaleOperateTypeEnum.java | 3 + .../HardCanteenUserApptDishStatisticsVo.java | 11 + ...rdCanteenUserApptDishUserStatisticsVo.java | 32 +++ .../order/HardAfterSaleController.java | 14 ++ .../model/HardCanteenReservationSaleFact.java | 3 + .../hard/service/HardAfterSaleService.java | 9 + .../hard/service/HardOrderService.java | 13 + .../impl/order/HardAfterSaleServiceImpl.java | 138 +++++++++- .../HardCanteenApptTotalServiceImpl.java | 71 +++++- .../impl/order/HardOrderServiceImpl.java | 31 ++- .../resources/mapper/order/HardCarMapper.xml | 1 + .../HardOrderServiceImplInventoryTest.java | 20 ++ .../impl/app/AppVersionServiceImpl.java | 10 +- .../pay/ThirdPayOrderBizTypeEnum.java | 2 +- .../dto/pay/ThirdMchFzRelationSaveDto.java | 9 +- .../entity/pay/ThirdMchFzRelationEntity.java | 6 +- .../vo/pay/ThirdMchFzRelationVo.java | 6 +- .../service/ThirdMchFzRelationService.java | 18 +- .../ThirdAggregateAliPayServiceImpl.java | 29 ++- .../ThirdAggregateOrderPayServiceImpl.java | 15 +- .../ThirdAppPlusAliPayServiceImpl.java | 120 +++++++-- .../impl/appplus/ThirdAppPlusServiceImpl.java | 24 +- .../pay/ThirdMchFzRelationServiceImpl.java | 235 +++++++++++++++--- .../mapper/ThirdPaymentTaskMapper.xml | 2 +- .../ThirdAppPlusAliPayServiceImplTest.java | 117 +++++++++ ...irdAppPlusServiceImplOrderCodePayTest.java | 56 ++++- .../ThirdMchFzRelationServiceImplTest.java | 155 +++++++++++- .../feign/RemoteWalletRechargeService.java | 10 + .../recharge/WalletRechargeController.java | 13 + .../wallet/service/WalletRechargeService.java | 8 + .../recharge/WalletRechargeServiceImpl.java | 119 ++++++++- .../WalletRechargeServiceImplTest.java | 84 +++++++ 33 files changed, 1267 insertions(+), 120 deletions(-) create mode 100644 db/20260827_add_third_mch_fz_relation_enable_fz.sql create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishUserStatisticsVo.java diff --git a/db/20260827_add_third_mch_fz_relation_enable_fz.sql b/db/20260827_add_third_mch_fz_relation_enable_fz.sql new file mode 100644 index 00000000..fb086bcd --- /dev/null +++ b/db/20260827_add_third_mch_fz_relation_enable_fz.sql @@ -0,0 +1,3 @@ +-- 为商户渠道关系增加分账开关;默认开启以保持历史关系原有的分账行为。 +ALTER TABLE `pjcloudx_thirdparty`.`third_mch_fz_relation` + ADD COLUMN `enable_fz` char(1) NOT NULL DEFAULT '1' COMMENT '是否开启分账:0仅提供渠道商户路由,1绑定接收方并执行平台抽佣' AFTER `fz_ratio`; 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 d90891ce..541e4bcf 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 @@ -18,6 +18,9 @@ public enum HardAfterSaleOperateTypeEnum { /** 商户审核拒绝退款申请。 */ ADMIN_DISAGREE_APPLY("12", "商家拒绝退款"), + /** 后台将人工审核完成的退款冲正并退回审核中。 */ + ADMIN_RETURN_TO_REVIEW("13", "商家将退款退回审核中"), + /** 会员补充退货物流信息。 */ MEMBER_DELIVERY("20", "会员填写退货物流信息,快递公司:{deliveryName},快递单号:{logisticsNo}"), 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 index 2fbd187d..cff3371a 100644 --- 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 @@ -3,6 +3,9 @@ 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; + /** * 用户预约按餐段和菜品快照聚合的统计数据。 */ @@ -18,6 +21,10 @@ public class HardCanteenUserApptDishStatisticsVo { @Schema(description = "菜品名称") private String productName; + /** 下单时固化的菜品图片地址;历史订单未保存图片时为空。 */ + @Schema(description = "菜品图片地址") + private String productPic; + /** 菜品快照的总份数。 */ @Schema(description = "菜品份数") private Long productCount = 0L; @@ -29,4 +36,8 @@ public class HardCanteenUserApptDishStatisticsVo { /** 该菜品快照总价,单位为分。 */ @Schema(description = "菜品总价,单位为分") private Long totalPrice = 0L; + + /** 点过该菜品的用户及各自净份数;按当前 UPMS 权限用户顺序返回。 */ + @Schema(description = "菜品点餐人员明细") + private List users = new ArrayList<>(); } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishUserStatisticsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishUserStatisticsVo.java new file mode 100644 index 00000000..88cd8b4b --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishUserStatisticsVo.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 HardCanteenUserApptDishUserStatisticsVo { + + /** 系统用户主键,来源于当前操作者数据权限范围内的 UPMS 用户。 */ + @Schema(description = "用户主键") + private Long userId; + + /** 页面展示姓名,依次取姓名、昵称和登录账号。 */ + @Schema(description = "用户姓名") + private String name; + + /** 用户登录账号,用于同名人员识别;未维护时为空。 */ + @Schema(description = "登录账号") + private String username; + + /** 用户当前所属部门名称;未分配部门时为空。 */ + @Schema(description = "部门名称") + private String deptName; + + /** 该用户在当前餐段和菜品统计项下完成退款扣减后的净份数。 */ + @Schema(description = "用户点餐份数") + private Long productCount = 0L; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java index 639db090..833c0015 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java @@ -200,6 +200,20 @@ public class HardAfterSaleController { return R.ok(hardAfterSaleService.disagreeRefund(user.getId(), dto)); } + /** + * 将误同意的人工钱包退款退回审核中,并同步冲正钱包入账和退款库存恢复。 + * + * @param id 已完成人工审核的售后单 ID + * @return 资金、库存和售后状态均恢复成功时返回 {@code true} + */ + @PutMapping("/return-to-review") + @Operation(summary = "退回到审核前") + @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_change')") + @Idempotent(expireTime = 10, key = "'/hard-after-sale/return-to-review' + #id", info = "正在处理中,请勿重复操作") + public R returnToReview(@RequestParam Long id) { + return R.ok(hardAfterSaleService.returnToReview(SecurityUtils.getUser().getId(), id)); + } + @Operation(summary = "分页查询", description = "分页查询") @GetMapping("/page") @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_view')") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardCanteenReservationSaleFact.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardCanteenReservationSaleFact.java index 7ba31b46..d063b778 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardCanteenReservationSaleFact.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardCanteenReservationSaleFact.java @@ -31,6 +31,9 @@ public class HardCanteenReservationSaleFact { /** 下单时保存的商品名称,历史未记录时可为空。 */ private String productName; + /** 下单时保存的商品图片地址,历史未记录时可为空。 */ + private String productPic; + /** 完成退款扣减后的有效商品份数,最小为零。 */ private Integer productCount; 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 90a46764..156b251b 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 @@ -109,6 +109,15 @@ public interface HardAfterSaleService extends IService { */ Boolean disagreeRefund(Long userId, HardDisagreeDto dto); + /** + * 将人工审核完成的钱包退款冲正并退回申请中,同时恢复退款前的库存占用和订单聚合状态。 + * + * @param userId 当前后台操作用户 ID + * @param id 售后单 ID + * @return 资金、库存和售后状态均已恢复时返回 {@code true} + */ + Boolean returnToReview(Long userId, Long id); + /** * 校验订单是否可以退款 */ 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 d5d02cba..e2bae5ad 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 @@ -294,6 +294,19 @@ public interface HardOrderService extends IService { */ void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce, Long afterSaleId); + /** + * 使用调用方提供的稳定业务号执行售后库存变更,用于同一售后单多次退回、再审核时隔离每轮库存动作。 + * 普通库存协议保持原行为,只有支持幂等的食堂基础 SPU 使用该业务号。 + * + * @param order 原订单 + * @param refundCarList 本次售后商品;为空表示整单商品 + * @param isReduce {@link YesNoEnum#YES} 扣库存,{@link YesNoEnum#NO} 恢复库存 + * @param afterSaleId 售后单 ID,用于校验退款库存上下文 + * @param inventoryBizId 本轮食堂基础 SPU 库存幂等业务号 + */ + void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce, + Long afterSaleId, String inventoryBizId); + /** * 核销硬件二维码 */ 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 6958a95b..f811bc7e 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 @@ -96,6 +96,12 @@ public class HardAfterSaleServiceImpl extends ServiceImpl 0 + ? RETURN_REVIEW_RESTORE_INVENTORY_BIZ_PREFIX + id + ":" + returnReviewCount : null; + restoreRefundInventory(order, id, inventoryBizId); } getSelf().refund(updateRefundObj.getId()); if (!canteenSpuInventoryOrder) { @@ -858,6 +867,104 @@ public class HardAfterSaleServiceImpl extends ServiceImpl getSelf().returnToReviewInTransaction(userId, id)); + } + + /** + * 在订单锁内冲正人工钱包退款,并恢复退款审核前的库存占用、售后状态和订单聚合状态。 + * 食堂基础 SPU 具备跨服务幂等补偿,先重新占用库存再扣回钱包;普通库存沿用历史接口, + * 先完成可幂等恢复的钱包冲正,再执行库存扣减,失败后可按仍为完成的售后状态继续重试。 + * + * @param userId 当前后台操作用户 ID + * @param id 已完成人工审核的售后单 ID + * @return 资金、库存和售后状态均恢复时返回 {@code true} + */ + @Transactional(rollbackFor = Exception.class) + public Boolean returnToReviewInTransaction(Long userId, Long id) { + HardAfterSaleEntity afterSale = baseMapper.selectById(id); + if (afterSale == null) { + throw new RuntimeException("售后单不存在"); + } + ensureNoAutoRefundTask(id); + long completedReturnCount = countReturnToReviewOperations(id); + if (ObjectUtil.equals(afterSale.getStatus(), HardSaleStatusEnum.APPLY.getStatus()) && completedReturnCount > 0) { + return true; + } + if (!ObjectUtil.equals(afterSale.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus())) { + throw new RuntimeException("仅已完成的人工审核退款可以退回审核中"); + } + + HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(afterSale.getOrderId()); + if (order == null) { + throw new RuntimeException("订单不存在"); + } + HardOrderTypeEnum orderType = HardOrderTypeEnum.valueOfByType(order.getOrderType()); + HardPayTypeEnum payType = HardPayTypeEnum.valueOfByType(order.getPayType()); + if (orderType == null || payType == null || !hardOrderService.isPay(orderType, payType)) { + throw new RuntimeException("仅钱包支付订单支持退回审核,第三方支付退款无法自动扣回"); + } + + long reviewCycle = completedReturnCount + 1; + String inventoryBizId = RETURN_REVIEW_REOCCUPY_INVENTORY_BIZ_PREFIX + id + ":" + reviewCycle; + boolean canteenSpuInventoryOrder = usesCanteenSpuInventory(order); + if (canteenSpuInventoryOrder) { + reoccupyRefundInventory(order, id, inventoryBizId); + } + rollbackWalletRefund(id); + if (!canteenSpuInventoryOrder) { + reoccupyRefundInventory(order, id, inventoryBizId); + } + + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(HardAfterSaleEntity::getId, id) + .eq(HardAfterSaleEntity::getStatus, HardSaleStatusEnum.COMPLETE.getStatus()) + .set(HardAfterSaleEntity::getStatus, HardSaleStatusEnum.APPLY.getStatus()) + .set(HardAfterSaleEntity::getAuditUserId, null) + .set(HardAfterSaleEntity::getAuditTime, null) + .set(HardAfterSaleEntity::getAuditReason, null) + .set(HardAfterSaleEntity::getRefundTime, null); + if (baseMapper.update(null, updateWrapper) != 1) { + throw new RuntimeException("退回售后审核状态失败"); + } + insertAfterSaleLog(id, userId, HardAfterSaleOperateTypeEnum.ADMIN_RETURN_TO_REVIEW.getType(), + HardSaleStatusEnum.COMPLETE.getStatus(), HardSaleStatusEnum.APPLY.getStatus(), + HardAfterSaleOperateTypeEnum.ADMIN_RETURN_TO_REVIEW.getContent()); + recomputeOrderRefundState(order.getId()); + return true; + } + + /** + * 调用 Wallet 冲正原售后单退款,并严格校验跨服务业务响应,远程异常时禁止推进本地售后状态。 + * + * @param afterSaleId 待冲正的 Hardware 售后单 ID + */ + private void rollbackWalletRefund(Long afterSaleId) { + R result = walletRechargeService.rollbackRefund(afterSaleId); + if (result == null || !result.isOk() || !Boolean.TRUE.equals(result.getData())) { + throw new RuntimeException(result == null || ObjectUtil.isEmpty(result.getMsg()) + ? "钱包退款金额扣回失败" : result.getMsg()); + } + } + + /** + * 统计售后单已经完成的退回审核次数,作为同一售后反复审核时库存幂等业务号的稳定轮次。 + * + * @param afterSaleId 售后单 ID + * @return 已持久化的退回审核日志数量 + */ + private long countReturnToReviewOperations(Long afterSaleId) { + return hardAfterSaleLogMapper.selectCount(new LambdaQueryWrapper() + .eq(HardAfterSaleLogEntity::getAfterSaleId, afterSaleId) + .eq(HardAfterSaleLogEntity::getOperateType, + HardAfterSaleOperateTypeEnum.ADMIN_RETURN_TO_REVIEW.getType())); + } + @Override public Boolean disagreeRefund(Long userId, HardDisagreeDto dto) { HardAfterSaleEntity current = baseMapper.selectById(dto.getId()); @@ -1853,15 +1960,40 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId); if (ObjectUtil.isEmpty(refundCars)) { - hardOrderService.fastenerInventory(order, null, YesNoEnum.NO, afterSaleId); + hardOrderService.fastenerInventory(order, null, YesNoEnum.NO, afterSaleId, inventoryBizId); return; } - hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO, afterSaleId); + hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO, afterSaleId, inventoryBizId); + } + + /** + * 重新占用本次退款曾恢复的商品库存。食堂基础 SPU 使用本轮独立业务号保证远程重试不重复扣减, + * 其他商品来源继续使用原库存协议和售后商品快照。 + * + * @param order 原订单 + * @param afterSaleId 退回审核的售后单 ID + * @param inventoryBizId 本轮库存重新占用的幂等业务号 + */ + private void reoccupyRefundInventory(HardOrderEntity order, Long afterSaleId, String inventoryBizId) { + List refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId); + List inventoryCars = ObjectUtil.isEmpty(refundCars) ? null : toInventoryCars(refundCars); + hardOrderService.fastenerInventory(order, inventoryCars, YesNoEnum.YES, afterSaleId, inventoryBizId); } /** 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 194e5e93..68f75b1b 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 @@ -22,6 +22,7 @@ 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.HardCanteenUserApptDishUserStatisticsVo; 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; @@ -807,6 +808,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ car.setParentCarId(order.getParentCarId()); car.setProductId(fact.getProductId()); car.setProductName(fact.getProductName()); + car.setProductPic(fact.getProductPic()); car.setProductCount(fact.getProductCount()); car.setTotalPrice(fact.getTotalPrice()); return car; @@ -1011,12 +1013,21 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ String productName = normalize(car.getProductName(), "未命名菜品"); String dishKey = hourType + "\u0000" + productName; DishStatisticsAccumulator dish = dishes.computeIfAbsent(dishKey, - key -> new DishStatisticsAccumulator(hourType, productName)); + key -> new DishStatisticsAccumulator(hourType, productName, car.getProductPic())); + // 同餐段同名菜品合并时优先保留首个非空快照图片,兼容早期订单未保存图片的情况。 + if (StrUtil.isBlank(dish.productPic) && StrUtil.isNotBlank(car.getProductPic())) { + dish.productPic = car.getProductPic(); + } dish.productCount += count; dish.totalPrice += number(car.getTotalPrice()); if (orderId != null) { dish.orderIds.add(orderId); } + if (row.getUserId() != null) { + DishUserStatisticsAccumulator user = dish.users.computeIfAbsent(row.getUserId(), + key -> new DishUserStatisticsAccumulator(row)); + user.productCount += count; + } } } } @@ -1124,6 +1135,9 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ /** 下单时固化的菜品名称。 */ private final String productName; + /** 下单时固化的菜品图片地址;同名菜品聚合时保留首个非空值。 */ + private String productPic; + /** 菜品快照份数。 */ private long productCount; @@ -1133,15 +1147,20 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ /** 包含该菜品的去重订单主键。 */ private final Set orderIds = new HashSet<>(); + /** 点过该菜品的用户主键到个人净份数的有序映射。 */ + private final Map users = new LinkedHashMap<>(); + /** * 创建指定餐段和菜品名称的聚合状态。 * * @param hourType 餐段协议编码或历史兼容文案 * @param productName 下单时保存的菜品名称 + * @param productPic 下单时保存的菜品图片地址,历史订单可能为空 */ - private DishStatisticsAccumulator(String hourType, String productName) { + private DishStatisticsAccumulator(String hourType, String productName, String productPic) { this.hourType = hourType; this.productName = productName; + this.productPic = productPic; } /** 转换为菜品统计响应。 */ @@ -1149,9 +1168,57 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ HardCanteenUserApptDishStatisticsVo vo = new HardCanteenUserApptDishStatisticsVo(); vo.setHourType(hourType); vo.setProductName(productName); + vo.setProductPic(productPic); vo.setProductCount(productCount); vo.setOrderCount((long) orderIds.size()); vo.setTotalPrice(totalPrice); + vo.setUsers(users.values().stream().map(DishUserStatisticsAccumulator::toVo).toList()); + return vo; + } + } + + /** 单个用户在指定餐段和菜品统计项下的可变聚合状态。 */ + private static final class DishUserStatisticsAccumulator { + + /** 系统用户主键,来源于当前统计行。 */ + private final Long userId; + + /** 页面展示姓名,来源于当前统计行。 */ + private final String name; + + /** 用户登录账号,来源于当前统计行。 */ + private final String username; + + /** 用户当前所属部门名称,来源于当前统计行。 */ + private final String deptName; + + /** 该用户点选当前菜品并扣除完成退款后的净份数。 */ + private long productCount; + + /** + * 从用户-日期统计行创建个人菜品聚合状态。 + * + * @param row 包含 UPMS 用户摘要的当前统计行 + */ + private DishUserStatisticsAccumulator(HardCanteenUserApptVo row) { + this.userId = row.getUserId(); + this.name = row.getUserName(); + this.username = row.getUsername(); + this.deptName = row.getDeptName(); + } + + /** + * 转换为菜品点餐人员响应。 + * + * @return 当前用户的身份摘要和菜品净份数 + */ + private HardCanteenUserApptDishUserStatisticsVo toVo() { + HardCanteenUserApptDishUserStatisticsVo vo = new HardCanteenUserApptDishUserStatisticsVo(); + vo.setUserId(userId); + vo.setName(name); + vo.setUsername(username); + vo.setDeptName(deptName); + vo.setProductCount(productCount); return vo; } } 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 589566fa..daa4c1cf 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 @@ -2145,7 +2145,7 @@ public class HardOrderServiceImpl extends ServiceImpl refundCarList, - YesNoEnum isReduce, Long afterSaleId, boolean validateOnly) { + YesNoEnum isReduce, Long afterSaleId, String inventoryBizId, + boolean validateOnly) { if (ObjectUtil.equals(isReduce, YesNoEnum.NO) && afterSaleId == null) { throw new RuntimeException("食堂菜品退款库存缺少售后单 ID"); } @@ -4223,11 +4225,11 @@ public class HardOrderServiceImpl extends ServiceImpl refundCarList, YesNoEnum isReduce, Long afterSaleId) { + getSelf().fastenerInventory(order, refundCarList, isReduce, afterSaleId, null); + } + + /** + * 按订单商品来源执行库存变更,并允许退款重审流程为食堂基础 SPU 指定本轮独立幂等业务号。 + * 普通库存协议不消费该业务号,继续保持原远程接口和事务顺序。 + * + * @param order 原订单 + * @param refundCarList 本次退款商品;为空表示使用整单购物车 + * @param isReduce 是否扣减库存 + * @param afterSaleId 本次售后单 ID,非退款或历史调用可为空 + * @param inventoryBizId 本轮食堂基础 SPU 库存幂等业务号;为空时沿用历史订单/售后业务号 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce, + Long afterSaleId, String inventoryBizId) { HardTypeEnum hardTypeEnum = HardTypeEnum.valueOfByType(order.getSysType()); HardOrderTypeEnum orderTypeEnum = HardOrderTypeEnum.valueOfByType(order.getOrderType()); if (!ObjectUtil.isAllNotEmpty(hardTypeEnum, orderTypeEnum)) return; @@ -4329,7 +4348,7 @@ public class HardOrderServiceImpl extends ServiceImpl 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 index 1aa7d24c..343aa99c 100644 --- 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 @@ -154,6 +154,26 @@ class HardOrderServiceImplInventoryTest { assertEquals(3, request.getItems().get(0).getBeforeStock()); } + /** + * 验证人工退款退回审核时可使用独立业务号重新扣减库存,避免命中首次退款恢复库存的幂等状态。 + */ + @Test + void refundReviewRollbackUsesIndependentInventoryBizId() { + 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.YES, 500L, + "HARD_AFTER_SALE_REVIEW_REOCCUPY:500:1")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class); + verify(remoteCanteenSpuService).updateSkuStockIdempotent(captor.capture()); + CanteenSpuUpdateStockReqDTO request = captor.getValue(); + assertEquals("HARD_AFTER_SALE_REVIEW_REOCCUPY:500:1", request.getBizId()); + assertEquals(-2, request.getItems().get(0).getIncrCount()); + } + /** * 准备机器收银购物车来源和商品数量。 * diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/app/AppVersionServiceImpl.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/app/AppVersionServiceImpl.java index 34c206f7..1355b227 100755 --- a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/app/AppVersionServiceImpl.java +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/app/AppVersionServiceImpl.java @@ -39,14 +39,20 @@ public class AppVersionServiceImpl extends ServiceImpl版本记录的平台字段由管理端多选后以英文逗号分隔存储,因此使用精确成员匹配, + * 避免平台编码 {@code 1} 误匹配 {@code 11} 等非目标编码。

+ * + * @param dto 当前版本码、单个平台编码及可选的 App 唯一编码 + * @return 版本码大于当前版本的最新已发布版本;没有可用更新时返回 {@code null} */ @Override public AppVersionEntity lastVersion(AppVersionDto dto) { LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper() .gt(ObjectUtil.isNotEmpty(dto.getVersionCode()), AppVersionEntity::getVersionCode, dto.getVersionCode()) .eq(AppVersionEntity::getStatus, AppVersionStatusEnum.PUBLISHED.getCode()) - .eq(ObjectUtil.isNotEmpty(dto.getPlatform()), AppVersionEntity::getPlatform, dto.getPlatform()) + .apply(ObjectUtil.isNotEmpty(dto.getPlatform()), "FIND_IN_SET({0}, platform) > 0", dto.getPlatform()) .eq(ObjectUtil.isNotEmpty(dto.getAppUniqueCode()), AppVersionEntity::getAppUniqueCode, dto.getAppUniqueCode()) .orderByDesc(AppVersionEntity::getVersionCode) .last("limit 1"); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPayOrderBizTypeEnum.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPayOrderBizTypeEnum.java index be8d8029..6edf0c2c 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPayOrderBizTypeEnum.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPayOrderBizTypeEnum.java @@ -13,7 +13,7 @@ import java.util.Arrays; @Getter public enum ThirdPayOrderBizTypeEnum { - MINI_APP_WALLET_RECHARGE("10", "小程序用户钱包充值"), + MINI_APP_WALLET_RECHARGE("10", "APP用户钱包充值"), YXFJ_PAY("20", "云消费机付款码支付"), APP_SHOP_PAY("30", "APP支付-购买商品(有订单)"), GUARDIAN_WARD_WALLET_RECHARGE("40", "监护人代被监护人饭卡充值"), diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdMchFzRelationSaveDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdMchFzRelationSaveDto.java index 146584fd..e3bb5ed8 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdMchFzRelationSaveDto.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdMchFzRelationSaveDto.java @@ -36,10 +36,10 @@ public class ThirdMchFzRelationSaveDto { @Size(max = 512, message = "支付宝授权令牌不能超过512个字符") private String appAuthToken; - /** 渠道接收方类型;新增时必填,编辑后变化时必须同时提交完整新标识并创建新关系版本。 */ + /** 渠道接收方类型;开启分账时必填,编辑后变化时必须同时提交完整新标识并创建新关系版本。 */ private String receiverType; - /** 渠道接收方账号、登录号、openId 或卡别名号;编辑时为空表示保留原完整值,非空变化时创建新关系版本。 */ + /** 渠道接收方账号、登录号、openId 或卡别名号;开启分账时必填,编辑时为空表示保留原完整值。 */ @Size(max = 128, message = "接收方标识不能超过128个字符") private String receiverAccount; @@ -47,9 +47,12 @@ public class ThirdMchFzRelationSaveDto { @Size(max = 128, message = "接收方名称不能超过128个字符") private String receiverName; - /** 平台抽佣比例,单位为百分比,最多保留三位小数;新增关系时必填,修改默认关系时可省略。 */ + /** 平台抽佣比例,单位为百分比,最多保留三位小数;开启分账时必须大于0,关闭时允许为空或0。 */ private Double fzRatio; + /** 是否开启分账,1为绑定接收方并抽佣、0为仅使用该关系提供支付渠道商户路由;为空时兼容为1。 */ + private String enableFz; + /** 微信分账关系类型;支付宝关系不使用,变化后原关系会使用新请求号重新绑定。 */ @Size(max = 64, message = "关系类型不能超过64个字符") private String channelRelationType; diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java index 3a0eedd7..9b39b804 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java @@ -44,14 +44,16 @@ public class ThirdMchFzRelationEntity extends Model { private String accountKey; /** 支付宝第三方代调用授权令牌;仅服务端渠道调用使用,管理接口不得返回完整值。 */ private String appAuthToken; - /** 渠道接收方类型,微信使用 SDK ReceiverType,支付宝支持 userId、loginName、openId、cardAliasNo。 */ + /** 渠道接收方类型,微信使用 SDK ReceiverType,支付宝支持 userId、loginName、openId、cardAliasNo;关闭分账时允许空字符串。 */ private String receiverType; - /** 平台分佣接收账号,发起渠道请求时使用;管理端展示必须脱敏。 */ + /** 平台分佣接收账号,发起渠道请求时使用;管理端展示必须脱敏,关闭分账时允许空字符串。 */ private String receiverAccount; /** 平台分佣接收方名称快照,渠道不要求时允许为空。 */ private String receiverName; /** 当前关系的平台抽佣比例,单位为百分比,范围为0到100,持久化精度为0.001%;新支付和分账金额只读取该关系快照。 */ private Double fzRatio; + /** 是否开启分账,0仅提供支付渠道商户路由、1同时执行接收方绑定和平台抽佣;历史数据默认按1处理。 */ + private String enableFz; /** 渠道关系类型,微信默认 SERVICE_PROVIDER,支付宝关系允许为空。 */ private String channelRelationType; /** 渠道绑定稳定幂等号;支付宝重试必须保持不变,微信用于本地审计。 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdMchFzRelationVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdMchFzRelationVo.java index c07bdfe3..df5de756 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdMchFzRelationVo.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdMchFzRelationVo.java @@ -26,14 +26,16 @@ public class ThirdMchFzRelationVo { private String accountKey; /** 是否已配置支付宝授权令牌;管理端只接收布尔状态,绝不返回完整令牌。 */ private Boolean appAuthTokenConfigured; - /** 接收方类型。 */ + /** 接收方类型;关闭分账且从未配置时为空。 */ private String receiverType; - /** 脱敏后的接收方标识。 */ + /** 脱敏后的接收方标识;关闭分账且从未配置时为空。 */ private String receiverAccount; /** 接收方展示名称。 */ private String receiverName; /** 当前关系的平台抽佣比例,单位为百分比,最多保留三位小数(0.001%)。 */ private Double fzRatio; + /** 是否开启分账,0表示仅用于支付渠道商户路由,1表示同时执行渠道关系绑定和平台抽佣。 */ + private String enableFz; /** 微信关系类型;支付宝为空。 */ private String channelRelationType; /** 当前关系绑定状态。 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java index 96e19bcb..214f939f 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java @@ -23,16 +23,17 @@ public interface ThirdMchFzRelationService { List listForManage(Long mchId, Long paySettingId); /** - * 新增或更新关系管理记录;渠道身份变化时创建新版本,避免覆盖历史支付订单引用的关系事实。 + * 新增或更新关系管理记录;渠道身份变化时创建新版本,相同身份已存在历史关系时恢复原主键。 * * @param dto 管理端关系参数 - * @return 保存后的脱敏关系;身份变化时返回新关系主键 + * @return 保存后的脱敏关系;身份变化时返回新关系主键,相同历史身份恢复时返回原关系主键 */ ThirdMchFzRelationVo saveForManage(ThirdMchFzRelationSaveDto dto); /** * 删除关系;已绑定关系只标记为历史关系,删除默认关系后允许当前支付配置暂时没有默认关系。 - * 新支付在新的默认关系绑定前会被支付前置校验拒绝,历史订单仍使用原关系快照。 + * 支付宝在没有默认关系时按普通支付处理;微信服务商支付仍需默认关系提供子商户路由。 + * 历史订单继续使用原关系快照完成退款或退分账。 * * @param ids 关系主键列表 * @return 全部处理成功时返回 true @@ -54,6 +55,17 @@ public interface ThirdMchFzRelationService { */ void syncDefaultRelations(MchManageVo merchant); + /** + * 查询支付创建时可选的默认支付渠道关系。 + * + *

完全未配置默认关系时返回 {@code null};关闭分账的默认关系只校验渠道归属并提供商户路由; + * 开启分账的默认关系还会严格校验抽佣比例和渠道绑定状态。

+ * + * @param setting 商户支付配置 + * @return 当前默认关系事实;未配置默认关系时返回 {@code null} + */ + ThirdMchFzRelationEntity findBoundDefault(MchPaySettingVo setting); + /** * 获取支付创建时必须使用的默认且已绑定关系;关系缺失或未绑定时直接拒绝支付。 * 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 26614ace..c24397af 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 @@ -155,9 +155,12 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ model.setSubject(SUBJECT); model.setProductCode(PRODUCT_CODE); model.setTimeExpire(LocalDateTimeUtil.format(context.getExpireTime(), DatePattern.NORM_DATETIME_PATTERN)); - SubMerchant subMerchant = new SubMerchant(); - subMerchant.setMerchantId(aliPaySetting.getSubMchid()); - model.setSubMerchant(subMerchant); + boolean enableFz = context.getFzRelationId() != null; + if (enableFz) { + SubMerchant subMerchant = new SubMerchant(); + subMerchant.setMerchantId(aliPaySetting.getSubMchid()); + model.setSubMerchant(subMerchant); + } if (StrUtil.isNotBlank(identity.getOpenId())) { model.setBuyerOpenId(identity.getOpenId()); } else { @@ -168,8 +171,8 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.getAliPayConfig(); request.setNotifyUrl(aliPayConfig.getBaseCallbackUrl() + aliPayConfig.getPayCallbackUrl()); request.setBizModel(model); - // app_auth_token 仅在商户配置了代调用授权时发送;为空时必须让支付宝按平台默认授权处理。 - if (StrUtil.isNotBlank(aliPaySetting.getAppAuthToken())) { + // 普通支付不携带关系专属授权;直付通仅在配置了代调用授权时发送 token。 + if (enableFz && StrUtil.isNotBlank(aliPaySetting.getAppAuthToken())) { request.putOtherTextParam(APP_AUTH_TOKEN_PARAM, aliPaySetting.getAppAuthToken()); } payOrder.setPayRep(buildRequestSnapshot(context).toString()); @@ -314,20 +317,20 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ } /** - * 校验商户支付宝配置并解析可选 app_auth_token。 + * 校验商户支付宝配置并解析可选 app_auth_token;普通支付允许配置 JSON 为空。 * * @param paySetting 当前商户启用的支付宝支付配置 - * @return 支付宝配置;授权令牌为空时仍允许直付通请求继续,由支付宝返回明确权限结果 + * @return 支付宝配置;无关系配置时返回空对象 */ private MchPaySettingEntity.AliPaySetting parseAliPaySetting(MchPaySettingVo paySetting) { - if (paySetting == null || StrUtil.isBlank(paySetting.getSettingJson())) { - throw new RuntimeException("商户支付宝支付配置不完整"); + if (paySetting == null) { + throw new RuntimeException("商户支付宝支付配置不存在"); + } + if (StrUtil.isBlank(paySetting.getSettingJson())) { + return new MchPaySettingEntity.AliPaySetting(); } MchPaySettingEntity.AliPaySetting setting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - if (setting == null || StrUtil.isBlank(setting.getSubMchid())) { - throw new RuntimeException("商户支付宝直付通二级商户标识未配置"); - } - return setting; + return setting == null ? new MchPaySettingEntity.AliPaySetting() : 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 34367cee..faa7cbcc 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 @@ -1205,15 +1205,22 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay } /** - * 聚合支付选定平台后解析默认关系,并生成只属于当前子订单的渠道配置快照。 + * 聚合支付选定平台后解析可选默认关系,并生成只属于当前子订单的渠道配置快照。 + * + *

默认关系始终提供渠道商户路由;只有关系明确开启分账时才写入分账关系主键。 + * 未配置默认关系时支付宝按普通支付创建子订单。

* * @param setting 已确认启用的支付配置 * @param context 聚合子订单创建上下文 - * @return 由关系事实生成的支付配置快照 + * @return 默认关系存在时返回渠道配置快照,否则返回原支付配置 */ private MchPaySettingVo applyDefaultPaymentRelation(MchPaySettingVo setting, ThirdAggregatePayCreateContext context) { - ThirdMchFzRelationEntity relation = mchFzRelationService.requireBound(setting); - context.setFzRelationId(relation.getId()); + ThirdMchFzRelationEntity relation = mchFzRelationService.findBoundDefault(setting); + if (relation == null) { + context.setFzRelationId(null); + return setting; + } + context.setFzRelationId(YesNoEnum.NO.getCode().equals(relation.getEnableFz()) ? null : relation.getId()); return mchFzRelationService.createPaymentSettingSnapshot(setting, relation); } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java index 215d0f1c..d1ef3531 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 @@ -22,6 +22,7 @@ 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.RefundRoyaltyResult; import com.alipay.api.domain.RoyaltyDetail; import com.alipay.api.domain.SettleDetailInfo; import com.alipay.api.domain.SettleInfo; @@ -336,7 +337,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySetting(setting); ThirdPayOrderEntity payOrder = buildOrderCodePayOrder(dto, setting, expireTime); - if (dto.getFzRelationId() != null) { + boolean enableFz = dto.getFzRelationId() != null; + if (enableFz) { ThirdMchFzRelationEntity relation = mchFzRelationService == null ? null : mchFzRelationService.getById(dto.getFzRelationId()); requirePaymentCreateRelation(relation, payOrder, aliPaySetting, setting); @@ -350,13 +352,17 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService model.setSubject(ORDER_CODE_SUBJECT); model.setProductCode(ORDER_CODE_PRODUCT_CODE); model.setTimeExpire(LocalDateTimeUtil.format(expireTime, DatePattern.NORM_DATETIME_PATTERN)); - SubMerchant subMerchant = new SubMerchant(); - subMerchant.setMerchantId(aliPaySetting.getSubMchid()); - model.setSubMerchant(subMerchant); + if (enableFz) { + SubMerchant subMerchant = new SubMerchant(); + subMerchant.setMerchantId(aliPaySetting.getSubMchid()); + model.setSubMerchant(subMerchant); + } payOrder.setPayRep(JSONUtil.toJsonStr(model)); request.setBizModel(model); - putAppAuthToken(request::putOtherTextParam, aliPaySetting); + if (enableFz) { + putAppAuthToken(request::putOtherTextParam, aliPaySetting); + } AlipayTradePrecreateResponse response; try { @@ -414,15 +420,19 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } /** - * app支付 + * 创建支付宝 App 支付订单。 + * + *

只有订单携带已绑定默认关系时才创建直付通请求;没有关系时按平台普通支付创建, + * 不发送二级商户、延迟结算或商户授权参数。

+ * + * @param dto 已由编排层校验的支付参数,金额单位为人民币分 + * @param setting 当前商户启用的支付宝支付配置或关系快照 + * @return 支付宝 SDK 签名订单串;渠道调用异常时返回 {@code null} */ public String appPay(ThirdAppPlusPayDto dto, MchPaySettingVo setting) { 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("支付宝直付通二级商户标识未配置"); - } + MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySetting(setting); ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity(); payOrder.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType()); @@ -439,11 +449,14 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting)); payOrder.setFzRelationId(dto.getFzRelationId()); validateFzRatioAndRelation(dto, setting); - if (dto.getAmount() == null || dto.getAmount() <= 10) { - throw new RuntimeException("支付宝直付通支付金额必须大于10分"); + if (dto.getAmount() == null || dto.getAmount() <= 0) { + throw new RuntimeException("支付宝支付金额必须大于0分"); } boolean enableFz = dto.getFzRelationId() != null; if (enableFz) { + if (dto.getAmount() <= 10) { + throw new RuntimeException("支付宝直付通支付金额必须大于10分"); + } // 上层通常已经通过 requireBound 解析关系;底层再次核对是为了防止内部调用者伪造 // fzRelationId,把其他商户、其他 smid 或尚未绑定的关系带入支付宝支付请求。 ThirdMchFzRelationEntity relation = mchFzRelationService == null @@ -466,12 +479,13 @@ 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"); - // 支付通必须填写`SettleDetailInfo` + // 直付通延迟结算必须填写默认结算明细,普通支付不能携带该参数。 ArrayList settleDetailInfoList = new ArrayList<>(); SettleDetailInfo defaultSettleDetailInfo = new SettleDetailInfo(); defaultSettleDetailInfo.setTransInType("defaultSettle"); @@ -814,7 +828,10 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService || StrUtil.isBlank(payOrder.getThirdPayNo()) || fzOrder.getPrice() == null) { return; } - if (ObjectUtil.equals(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_REFUNDING.getType())) { + if (ObjectUtil.equals(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_REFUNDING.getType()) + || (ObjectUtil.equals(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType()) + && StrUtil.isNotBlank(fzOrder.getFzRefundNo()))) { + // 支付宝渠道事实可以纠正历史版本因 result_code 缺失造成的本地失败误判;这里只查单,不重提退款。 pollAlipayFzRefund(fzOrder, payOrder); return; } @@ -1114,21 +1131,67 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService 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("支付宝退款成功但退分账结果缺失或失败"); + List refundRoyaltys = response.getRefundRoyaltys(); + if (refundRoyaltys == null || refundRoyaltys.isEmpty()) { + // 普通退款成功不等于退分账成功;明细可能延迟返回,保留处理中等待下一次查询。 + update.setStatus(ThirdFzOrderStatusEnum.FZ_REFUNDING.getType()); + update.setFzRefundError("支付宝退款成功,退分账明细尚未返回"); + } else if (refundRoyaltys.stream().anyMatch(this::isExplicitlyFailedRefundRoyalty)) { + update.setStatus(ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType()); + update.setFzRefundError("支付宝退款成功但退分账明细明确失败"); + } else if (refundRoyaltys.stream().allMatch(this::isCompletedRefundRoyalty)) { + update.setStatus(ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType()); + } else { + update.setStatus(ThirdFzOrderStatusEnum.FZ_REFUNDING.getType()); + 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() + LambdaUpdateWrapper condition = new LambdaUpdateWrapper() .eq(ThirdFzOrderEntity::getId, fzOrder.getId()) - .eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_REFUNDING.getType())); + .in(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_REFUNDING.getType(), + ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType()); + // 使用 Wrapper 显式写 NULL,避免实体更新策略忽略空值而残留历史版本的误判错误。 + condition.set(ObjectUtil.equals(update.getStatus(), ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType()), + ThirdFzOrderEntity::getFzRefundError, null); + thirdFzOrderService.update(update, condition); + } + + /** + * 判断支付宝退分账明细是否明确失败。 + * + *

{@code result_code} 在支付宝实际成功响应中可能不返回,只有非空且不是 SUCCESS 时才代表明确失败。

+ * + * @param royalty 支付宝退款查询返回的单条退分账明细 + * @return 明细明确携带失败结果码时返回 {@code true} + */ + private boolean isExplicitlyFailedRefundRoyalty(RefundRoyaltyResult royalty) { + return royalty != null && StrUtil.isNotBlank(royalty.getResultCode()) + && !"SUCCESS".equalsIgnoreCase(royalty.getResultCode()); + } + + /** + * 判断支付宝退分账明细是否已经完整返回。 + * + *

退款主状态为 REFUND_SUCCESS 且存在正数退分账金额即可确认该明细完成; + * {@code result_code} 缺失属于支付宝真实成功响应,不能据此判失败。

+ * + * @param royalty 支付宝退款查询返回的单条退分账明细 + * @return 明细包含有效退款金额且没有明确失败结果码时返回 {@code true} + */ + private boolean isCompletedRefundRoyalty(RefundRoyaltyResult royalty) { + if (royalty == null || StrUtil.isBlank(royalty.getRefundAmount()) + || isExplicitlyFailedRefundRoyalty(royalty)) { + return false; + } + try { + return new BigDecimal(royalty.getRefundAmount()).compareTo(BigDecimal.ZERO) > 0; + } catch (NumberFormatException exception) { + return false; + } } /** @@ -1716,17 +1779,20 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService } /** - * 解析当前商户支付宝配置;授权令牌允许为空,账号选择和直付通标识由具体调用继续校验。 + * 解析当前商户支付宝配置;普通支付允许没有关系专属配置。 * * @param setting 当前商户支付配置 - * @return 支付宝商户授权配置 + * @return 支付宝商户授权配置;配置 JSON 为空时返回空配置对象 */ private MchPaySettingEntity.AliPaySetting parseAliPaySetting(MchPaySettingVo setting) { - if (setting == null || StrUtil.isBlank(setting.getSettingJson())) { - throw new RuntimeException("商户支付宝支付配置不完整"); + if (setting == null) { + throw new RuntimeException("商户支付宝支付配置不存在"); + } + if (StrUtil.isBlank(setting.getSettingJson())) { + return new MchPaySettingEntity.AliPaySetting(); } MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - return aliPaySetting; + return aliPaySetting == null ? new MchPaySettingEntity.AliPaySetting() : aliPaySetting; } /** 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 44dd31c9..69d40742 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 @@ -743,25 +743,35 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { } /** - * 校验订单码支付首版允许的业务、平台、金额和商户参数。 - */ - /** - * 解析默认关系并生成订单级渠道配置快照,二级商户与支付宝授权均不得再从支付配置 JSON 读取。 + * 解析可选默认分账关系并生成订单级渠道配置快照。 + * + *

默认关系始终提供渠道商户路由;只有关系明确开启分账时才把关系主键传给渠道服务。 + * 未配置默认关系时不携带渠道关系参数,支付宝按普通支付处理。

* * @param paySetting 已由商户服务确认启用的支付配置 * @param payDto 即将提交渠道服务的内部支付参数 - * @return 由关系事实生成的独立支付配置快照 + * @return 默认关系存在时返回渠道配置快照,否则返回原支付配置 */ private MchPaySettingVo applyDefaultPaymentRelation(MchPaySettingVo paySetting, ThirdAppPlusPayDto payDto) { if (mchFzRelationService == null) { throw new IllegalStateException("支付平台分佣关系服务不可用"); } - com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity relation = mchFzRelationService.requireBound(paySetting); - payDto.setFzRelationId(relation.getId()); + com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity relation = mchFzRelationService.findBoundDefault(paySetting); + if (relation == null) { + payDto.setFzRelationId(null); + payDto.setSubMchid(null); + return paySetting; + } + payDto.setFzRelationId(YesNoEnum.NO.getCode().equals(relation.getEnableFz()) ? null : relation.getId()); payDto.setSubMchid(relation.getChannelSubMchId()); return mchFzRelationService.createPaymentSettingSnapshot(paySetting, relation); } + /** + * 校验订单码支付首版允许的业务、平台、金额和商户参数。 + * + * @param dto 服务端订单码支付请求 + */ private void validateOrderCodePayRequest(ThirdOrderCodePayDto dto) { if (dto == null) { throw new RuntimeException("订单码支付参数不能为空"); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java index 637de9ff..99265488 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java @@ -53,10 +53,11 @@ import java.util.Objects; import java.util.Set; /** - * 商户微信/支付宝默认平台分佣关系实现。 + * 商户微信/支付宝可选平台分佣关系实现。 * - *

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

+ *

未配置默认关系时支付按普通交易处理;一旦配置关系,渠道绑定仍是启用分账的前置条件。 + * 外部渠道调用由 Quartz 的 {@code FZ_RELATION_BIND} 任务执行,本服务只负责关系事实物化、 + * 渠道请求和结果快照。

*/ @Slf4j @Service @@ -146,7 +147,8 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService * 新增或编辑关系配置。渠道身份变化时创建新关系版本,非身份渠道字段变化时重置原关系并重新绑定。 * *

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

+ * 不能覆盖原记录;旧记录保留为 {@code SUPERSEDED},新记录使用独立幂等号等待 Quartz 绑定。 + * 管理员再次新增相同渠道身份时恢复原历史关系主键,避免唯一键冲突和重复渠道绑定。

* * @param dto 管理端关系参数;编辑时接收方标识为空表示保留服务端完整值 * @return 保存后的脱敏关系;身份变化时返回新关系主键 @@ -175,23 +177,27 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService if (existing == null) { throw new IllegalArgumentException("分账关系不存在"); } - if (ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode().equals(existing.getStatus())) { - throw new IllegalArgumentException("历史分账关系不能编辑,请维护当前关系"); - } if (!matchesCurrentSetting(existing, setting)) { throw new IllegalArgumentException("历史渠道配置下的关系不能重新用于当前支付配置"); } - boolean wasDefault = YesNoEnum.YES.getCode().equals(existing.getIsDefault()); - relation = updateManageRelation(tenantId, dto, setting, existing); - inheritDefault = wasDefault && !Objects.equals(existing.getId(), relation.getId()); + if (ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode().equals(existing.getStatus())) { + ThirdMchFzRelationSaveDto effective = normalizeManageEdit(dto, setting, existing); + validateManageReceiver(effective, setting); + validateRelationRatio(effective.getFzRatio(), isProfitSharingEnabled(effective.getEnableFz())); + relation = restoreSupersededRelation(tenantId, buildManageRelation(tenantId, effective, setting)); + } else { + boolean wasDefault = YesNoEnum.YES.getCode().equals(existing.getIsDefault()); + relation = updateManageRelation(tenantId, dto, setting, existing); + inheritDefault = wasDefault && !Objects.equals(existing.getId(), relation.getId()); + } } else { validateManageReceiver(dto, setting); - validateRelationRatio(dto.getFzRatio(), true); + validateRelationRatio(dto.getFzRatio(), isProfitSharingEnabled(dto.getEnableFz())); relation = buildManageRelation(tenantId, dto, setting); try { relationMapper.insert(relation); } catch (DuplicateKeyException exception) { - throw new IllegalArgumentException("相同渠道接收方关系已存在,请直接设为默认"); + relation = restoreSupersededRelation(tenantId, relation); } } // 首条关系由服务端自动成为默认关系,不能把支付可用性建立在前端是否正确提交开关上。 @@ -199,7 +205,7 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService || !hasDefaultRelation(tenantId, dto.getPaySettingId())) { setDefault(relation); } - if (relation.getFzRatio() != null && relation.getFzRatio() > 0 + if (isProfitSharingEnabled(relation) && relation.getFzRatio() != null && relation.getFzRatio() > 0 && !ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(relation.getStatus())) { taskEnqueuer.enqueueRelationBind(tenantId, relation.getId(), LocalDateTime.now(), RELATION_TASK_PRIORITY); } @@ -220,14 +226,14 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService MchPaySettingVo setting, ThirdMchFzRelationEntity existing) { ThirdMchFzRelationSaveDto effective = normalizeManageEdit(dto, setting, existing); validateManageReceiver(effective, setting); - validateRelationRatio(effective.getFzRatio(), false); + validateRelationRatio(effective.getFzRatio(), isProfitSharingEnabled(effective.getEnableFz())); String channelAppId = resolveChannelAppId(setting); if (hasChannelIdentityChanged(existing, effective, channelAppId)) { ThirdMchFzRelationEntity replacement = buildManageRelation(tenantId, effective, setting); try { relationMapper.insert(replacement); } catch (DuplicateKeyException exception) { - throw new IllegalArgumentException("相同渠道接收方关系已存在,请直接设为默认"); + replacement = restoreSupersededRelation(tenantId, replacement); } supersedeRelation(existing); return replacement; @@ -235,6 +241,85 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService return updateCurrentRelation(tenantId, effective, setting, existing); } + /** + * 恢复与新增参数具有相同唯一身份的历史关系,继续复用原主键和已绑定渠道事实。 + * + *

关系唯一键包含支付配置、渠道应用、二级商户和接收方身份。命中非历史关系仍视为重复配置; + * 只有 {@code SUPERSEDED} 可以恢复。接收方名称或微信关系类型变化时生成新的稳定绑定请求号并重新排队, + * 完全相同且曾经绑定成功的关系直接恢复为 {@code BOUND}。

+ * + * @param tenantId 当前租户ID,作为关系查询和状态更新的隔离边界 + * @param requested 本次新增构造的完整关系参数,不会直接插入数据库 + * @return 复用原主键并恢复后的关系内存快照 + */ + private ThirdMchFzRelationEntity restoreSupersededRelation(Long tenantId, ThirdMchFzRelationEntity requested) { + ThirdMchFzRelationEntity existing = relationMapper.selectOne(new LambdaQueryWrapper() + .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) + .eq(ThirdMchFzRelationEntity::getMchId, requested.getMchId()) + .eq(ThirdMchFzRelationEntity::getPaySettingId, requested.getPaySettingId()) + .eq(ThirdMchFzRelationEntity::getPlatformType, requested.getPlatformType()) + .eq(ThirdMchFzRelationEntity::getRelationScene, requested.getRelationScene()) + .eq(ThirdMchFzRelationEntity::getChannelAppId, requested.getChannelAppId()) + .eq(ThirdMchFzRelationEntity::getChannelSubMchId, requested.getChannelSubMchId()) + .eq(ThirdMchFzRelationEntity::getReceiverType, requested.getReceiverType()) + .eq(ThirdMchFzRelationEntity::getReceiverAccount, requested.getReceiverAccount()) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0") + .last("LIMIT 1")); + if (existing == null || !ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode().equals(existing.getStatus())) { + throw new IllegalArgumentException("相同渠道接收方关系已存在,请直接设为默认"); + } + + boolean rebindRequired = !Objects.equals(existing.getReceiverName(), requested.getReceiverName()) + || (WECHAT_TYPE.equals(requested.getPlatformType()) + && !Objects.equals(existing.getChannelRelationType(), requested.getChannelRelationType())); + ThirdMchFzRelationEntity restored = BeanUtil.toBean(existing, ThirdMchFzRelationEntity.class); + restored.setAccountKey(requested.getAccountKey()); + // 管理端不会回显完整授权令牌,新增相同历史关系时留空必须沿用原值,避免恢复后支付宝代调用失效。 + restored.setAppAuthToken(StrUtil.isBlank(requested.getAppAuthToken()) + ? existing.getAppAuthToken() : requested.getAppAuthToken()); + restored.setReceiverName(requested.getReceiverName()); + restored.setFzRatio(requested.getFzRatio()); + restored.setEnableFz(requested.getEnableFz()); + restored.setChannelRelationType(requested.getChannelRelationType()); + boolean canReuseBoundRelation = !rebindRequired && existing.getBoundTime() != null; + restored.setStatus(canReuseBoundRelation + ? ThirdMchFzRelationStatusEnum.BOUND.getCode() + : ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode()); + + LambdaUpdateWrapper update = new LambdaUpdateWrapper() + .eq(ThirdMchFzRelationEntity::getId, existing.getId()) + .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) + .eq(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode()) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0") + .set(ThirdMchFzRelationEntity::getAccountKey, restored.getAccountKey()) + .set(ThirdMchFzRelationEntity::getAppAuthToken, restored.getAppAuthToken()) + .set(ThirdMchFzRelationEntity::getReceiverName, restored.getReceiverName()) + .set(ThirdMchFzRelationEntity::getFzRatio, restored.getFzRatio()) + .set(ThirdMchFzRelationEntity::getEnableFz, restored.getEnableFz()) + .set(ThirdMchFzRelationEntity::getChannelRelationType, restored.getChannelRelationType()) + .set(ThirdMchFzRelationEntity::getStatus, restored.getStatus()); + if (!canReuseBoundRelation) { + restored.setOutRequestNo(newRelationRequestNo(requested.getPlatformType(), existing.getId())); + restored.setBindReq(buildBindRequestSnapshot(restored)); + restored.setBindRes(null); + restored.setBindError(null); + restored.setLastExternalStatus(null); + restored.setLastQueryTime(null); + restored.setBoundTime(null); + update.set(ThirdMchFzRelationEntity::getOutRequestNo, restored.getOutRequestNo()) + .set(ThirdMchFzRelationEntity::getBindReq, restored.getBindReq()) + .set(ThirdMchFzRelationEntity::getBindRes, null) + .set(ThirdMchFzRelationEntity::getBindError, null) + .set(ThirdMchFzRelationEntity::getLastExternalStatus, null) + .set(ThirdMchFzRelationEntity::getLastQueryTime, null) + .set(ThirdMchFzRelationEntity::getBoundTime, null); + } + if (relationMapper.update(null, update) != 1) { + throw new IllegalStateException("历史分账关系状态已变化,请刷新后重试"); + } + return restored; + } + /** * 合并编辑参数与数据库完整值。管理端永远拿不到完整接收账号,因此空账号表示保持原值而不是清空。 * @@ -266,6 +351,8 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService effective.setReceiverName(dto.getReceiverName() == null ? existing.getReceiverName() : StrUtil.trim(dto.getReceiverName())); effective.setFzRatio(dto.getFzRatio() == null ? existing.getFzRatio() : dto.getFzRatio()); + effective.setEnableFz(dto.getEnableFz() == null + ? normalizeEnableFz(existing.getEnableFz()) : normalizeEnableFz(dto.getEnableFz())); if (WECHAT_TYPE.equals(setting.getPlatformType())) { effective.setChannelRelationType(StrUtil.isBlank(dto.getChannelRelationType()) ? existing.getChannelRelationType() : StrUtil.trim(dto.getChannelRelationType())); @@ -315,6 +402,7 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService updated.setAppAuthToken(effective.getAppAuthToken()); updated.setReceiverName(effective.getReceiverName()); updated.setFzRatio(effective.getFzRatio()); + updated.setEnableFz(effective.getEnableFz()); updated.setChannelRelationType(effective.getChannelRelationType()); LambdaUpdateWrapper update = new LambdaUpdateWrapper() .eq(ThirdMchFzRelationEntity::getId, existing.getId()) @@ -324,6 +412,7 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService .set(ThirdMchFzRelationEntity::getAppAuthToken, updated.getAppAuthToken()) .set(ThirdMchFzRelationEntity::getReceiverName, updated.getReceiverName()) .set(ThirdMchFzRelationEntity::getFzRatio, updated.getFzRatio()) + .set(ThirdMchFzRelationEntity::getEnableFz, updated.getEnableFz()) .set(ThirdMchFzRelationEntity::getChannelRelationType, updated.getChannelRelationType()); if (rebindRequired) { updated.setOutRequestNo(newRelationRequestNo(setting.getPlatformType(), existing.getId())); @@ -384,7 +473,8 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService * 删除关系;已绑定关系保留为历史事实,只移出默认候选,避免历史订单无法退分账。 * *

删除默认关系不再强制寻找替代关系。删除后当前支付配置可以暂时没有默认关系, - * 新支付会由支付前置校验拒绝,直到管理端新增并绑定新的默认关系;历史订单仍按原关系快照继续退款和退分账。

+ * 支付宝新订单按普通支付处理;微信服务商支付仍需配置一条提供子商户路由的默认关系。 + * 历史订单继续按原关系快照退款和退分账。

* * @param ids 关系主键 * @return 是否全部处理成功 @@ -439,6 +529,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService if (ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode().equals(relation.getStatus())) { throw new IllegalArgumentException("历史分账关系不能重试,请新增关系后绑定"); } + if (!isProfitSharingEnabled(relation)) { + throw new IllegalArgumentException("当前关系未开启分账,无需绑定接收方"); + } relationMapper.update(null, new LambdaUpdateWrapper() .eq(ThirdMchFzRelationEntity::getId, id) .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) @@ -497,14 +590,14 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService } /** - * 校验支付配置当前默认关系已明确绑定。 + * 查询支付配置当前可选的默认渠道关系;没有默认关系表示按渠道允许的普通支付处理。 * * @param setting 商户支付配置 - * @return 当前支付必须使用的默认且已绑定关系 + * @return 默认关系;关闭分账时不要求绑定,未配置默认关系时返回 {@code null} */ @Override @Transactional(rollbackFor = Exception.class) - public ThirdMchFzRelationEntity requireBound(MchPaySettingVo setting) { + public ThirdMchFzRelationEntity findBoundDefault(MchPaySettingVo setting) { if (!isEligible(setting)) { throw new IllegalStateException("商户支付配置未启用或不支持当前渠道"); } @@ -519,11 +612,30 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService .eq(ThirdMchFzRelationEntity::getDelFlag, "0") .last("LIMIT 1")); if (relation == null) { - throw new IllegalStateException("商户未配置默认支付分账关系"); + return null; } return validatePaymentRelation(relation, setting, tenantId); } + /** + * 严格获取默认且已绑定关系,供明确要求必须分账的内部场景继续使用。 + * + * @param setting 商户支付配置 + * @return 当前默认且已绑定的关系事实 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public ThirdMchFzRelationEntity requireBound(MchPaySettingVo setting) { + ThirdMchFzRelationEntity relation = findBoundDefault(setting); + if (relation == null) { + throw new IllegalStateException("商户未配置默认支付分账关系"); + } + if (!isProfitSharingEnabled(relation)) { + throw new IllegalStateException("商户默认支付关系未开启分账"); + } + return relation; + } + /** * {@inheritDoc} */ @@ -541,9 +653,12 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService snapshot.setSettingJson(JSONUtil.toJsonStr(channel)); } else if (ALIPAY_TYPE.equals(setting.getPlatformType())) { MchPaySettingEntity.AliPaySetting channel = new MchPaySettingEntity.AliPaySetting(); + // 关闭分账的支付宝关系只承担本地配置归属,不得把直付通商户或授权参数带入普通支付。 + if (isProfitSharingEnabled(relation)) { + channel.setAccountKey(relation.getAccountKey()); + channel.setAppAuthToken(relation.getAppAuthToken()); + } channel.setSubMchid(relation.getChannelSubMchId()); - channel.setAccountKey(relation.getAccountKey()); - channel.setAppAuthToken(relation.getAppAuthToken()); snapshot.setSettingJson(JSONUtil.toJsonStr(channel)); } else { throw new IllegalArgumentException("不支持的支付平台关系"); @@ -574,7 +689,11 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService .eq(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode()) .eq(ThirdMchFzRelationEntity::getDelFlag, "0") .last("LIMIT 1")); - return validatePaymentRelation(relation, setting, tenantId); + ThirdMchFzRelationEntity validated = validatePaymentRelation(relation, setting, tenantId); + if (!isProfitSharingEnabled(validated)) { + throw new IllegalStateException("支付平台关系未开启分账"); + } + return validated; } /** @@ -600,6 +719,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService if (relation == null || !"0".equals(relation.getDelFlag()) || ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode().equals(relation.getStatus())) { return true; } + if (!isProfitSharingEnabled(relation)) { + return true; + } if (ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(relation.getStatus())) { return true; } @@ -637,10 +759,11 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService private ThirdMchFzRelationEntity buildManageRelation(Long tenantId, ThirdMchFzRelationSaveDto dto, MchPaySettingVo setting) { ThirdMchFzRelationEntity result = buildChannelRelation(tenantId, dto, setting); - result.setReceiverType(StrUtil.trim(dto.getReceiverType())); - result.setReceiverAccount(StrUtil.trim(dto.getReceiverAccount())); + result.setReceiverType(StrUtil.isBlank(dto.getReceiverType()) ? "" : StrUtil.trim(dto.getReceiverType())); + result.setReceiverAccount(StrUtil.isBlank(dto.getReceiverAccount()) ? "" : StrUtil.trim(dto.getReceiverAccount())); result.setReceiverName(StrUtil.trim(dto.getReceiverName())); - result.setFzRatio(dto.getFzRatio()); + result.setFzRatio(dto.getFzRatio() == null ? 0D : dto.getFzRatio()); + result.setEnableFz(normalizeEnableFz(dto.getEnableFz())); if (WECHAT_TYPE.equals(setting.getPlatformType())) { result.setChannelRelationType(StrUtil.blankToDefault(dto.getChannelRelationType(), ReceiverRelationType.SERVICE_PROVIDER.name())); } @@ -697,6 +820,7 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService .set("receiverType", relation.getReceiverType()) .set("receiverAccount", mask(relation.getReceiverAccount())) .set("receiverName", relation.getReceiverName()) + .set("enableFz", normalizeEnableFz(relation.getEnableFz())) .set("channelSubMchId", relation.getChannelSubMchId()) .set("channelRelationType", relation.getChannelRelationType()) .set("accountKey", relation.getAccountKey()) @@ -707,15 +831,19 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService * 管理端接收方类型、账号及微信关系类型校验。 */ private void validateManageReceiver(ThirdMchFzRelationSaveDto dto, MchPaySettingVo setting) { + boolean profitSharingEnabled = isProfitSharingEnabled(dto.getEnableFz()); String receiverType = StrUtil.trim(dto.getReceiverType()); String account = StrUtil.trim(dto.getReceiverAccount()); - if (StrUtil.isBlank(receiverType) || StrUtil.isBlank(account)) { + if (profitSharingEnabled && (StrUtil.isBlank(receiverType) || StrUtil.isBlank(account))) { throw new IllegalArgumentException("接收方类型和标识不能为空"); } if (StrUtil.isBlank(dto.getChannelSubMchId())) { throw new IllegalArgumentException(WECHAT_TYPE.equals(setting.getPlatformType()) ? "微信子商户号不能为空" : "支付宝直付通二级商户标识不能为空"); } + if (!profitSharingEnabled) { + return; + } if (ALIPAY_TYPE.equals(setting.getPlatformType()) && !ALIPAY_RECEIVER_TYPES.contains(receiverType)) { throw new IllegalArgumentException("支付宝接收方只能是userId、loginName、openId或cardAliasNo"); } @@ -838,6 +966,7 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService vo.setReceiverAccount(mask(relation.getReceiverAccount())); vo.setReceiverName(relation.getReceiverName()); vo.setFzRatio(relation.getFzRatio()); + vo.setEnableFz(normalizeEnableFz(relation.getEnableFz())); vo.setChannelRelationType(relation.getChannelRelationType()); vo.setStatus(relation.getStatus()); vo.setIsDefault(relation.getIsDefault()); @@ -857,7 +986,7 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService } /** - * 校验关系抽佣比例;新增关系必须启用抽佣,已有或自动关系允许零值关闭抽佣。 + * 校验关系抽佣比例;开启分账时必须为正数,关闭分账时允许零值保留纯支付路由。 */ private void validateRelationRatio(Double ratio, boolean required) { if (ratio == null) { @@ -879,15 +1008,20 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService } /** - * 校验关系的当前渠道归属、比例与绑定状态,并在尚未绑定时确保恢复任务存在。 + * 校验关系的当前渠道归属;仅开启分账时继续校验接收方、比例与绑定状态。 */ private ThirdMchFzRelationEntity validatePaymentRelation(ThirdMchFzRelationEntity relation, MchPaySettingVo setting, Long tenantId) { if (relation == null || !matchesCurrentSetting(relation, setting) - || StrUtil.hasBlank(relation.getChannelAppId(), relation.getChannelSubMchId(), - relation.getReceiverType(), relation.getReceiverAccount())) { + || StrUtil.hasBlank(relation.getChannelAppId(), relation.getChannelSubMchId())) { throw new IllegalStateException("支付平台分佣关系与当前商户渠道配置不一致"); } + if (!isProfitSharingEnabled(relation)) { + return relation; + } + if (StrUtil.hasBlank(relation.getReceiverType(), relation.getReceiverAccount())) { + throw new IllegalStateException("支付平台分佣接收方配置不完整"); + } validateRelationRatio(relation.getFzRatio(), true); if (relation.getFzRatio() <= 0) { throw new IllegalStateException("支付平台分佣关系未启用抽佣"); @@ -899,6 +1033,40 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService return relation; } + /** + * 判断关系是否开启分账;滚动发布期间旧实例或旧测试对象没有该字段时按历史启用语义处理。 + * + * @param relation 关系事实 + * @return 仅明确保存为0时返回 {@code false} + */ + private boolean isProfitSharingEnabled(ThirdMchFzRelationEntity relation) { + return relation != null && isProfitSharingEnabled(relation.getEnableFz()); + } + + /** + * 判断管理参数是否开启分账;空值按数据库默认值1处理,保证旧管理端请求向后兼容。 + * + * @param enableFz 是否开启分账编码 + * @return 仅明确保存为0时返回 {@code false} + */ + private boolean isProfitSharingEnabled(String enableFz) { + return !YesNoEnum.NO.getCode().equals(enableFz); + } + + /** + * 规范化是否开启分账编码,拒绝除0和1以外的持久化值。 + * + * @param enableFz 管理端或数据库中的原始编码 + * @return 可直接持久化的0或1,空值返回1 + */ + private String normalizeEnableFz(String enableFz) { + String normalized = StrUtil.blankToDefault(StrUtil.trim(enableFz), YesNoEnum.YES.getCode()); + if (!List.of(YesNoEnum.YES.getCode(), YesNoEnum.NO.getCode()).contains(normalized)) { + throw new IllegalArgumentException("是否开启分账只能为0或1"); + } + return normalized; + } + /** * 微信关系绑定。 */ @@ -1204,7 +1372,10 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService * 脱敏账号。 */ private String mask(String value) { - if (StrUtil.isBlank(value) || value.length() <= 4) { + if (StrUtil.isBlank(value)) { + return null; + } + if (value.length() <= 4) { return "****"; } return value.substring(0, 2) + "****" + value.substring(value.length() - 2); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml index 4a01b1ff..9f563dd7 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml @@ -74,7 +74,7 @@ r.id AS fz_relation_id, 150 AS priority, COALESCE(r.update_time, r.create_time, #{now}) AS next_run_time FROM third_mch_fz_relation r - WHERE r.del_flag = '0' AND r.is_default = '1' + WHERE r.del_flag = '0' AND r.is_default = '1' AND r.enable_fz = '1' AND r.status IN ('PENDING_BIND', 'BINDING') AND NOT EXISTS ( SELECT 1 FROM third_payment_task t 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 bcd7f6a6..b0bb818e 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 @@ -11,6 +11,7 @@ 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.RefundRoyaltyResult; import com.alipay.api.domain.RoyaltyDetail; import com.alipay.api.request.AlipayTradeAppPayRequest; import com.alipay.api.request.AlipayTradeFastpayRefundQueryRequest; @@ -196,6 +197,31 @@ class ThirdAppPlusAliPayServiceImplTest { Assertions.assertFalse(requestCaptor.getValue().getTextParams().containsKey("app_auth_token")); } + /** 无默认关系时 App 支付必须走普通支付宝交易,不携带任何直付通或商户授权参数。 */ + @Test + void appPayShouldCreateOrdinaryOrderWithoutCommissionRelation() throws Exception { + payDto.setFzRelationId(null); + paySetting.setSettingJson(null); + AlipayTradeAppPayResponse response = new AlipayTradeAppPayResponse(); + response.setBody("signed-ordinary-alipay-order"); + when(alipayClient.sdkExecute(any(AlipayTradeAppPayRequest.class))).thenReturn(response); + + String result = service.appPay(payDto, paySetting); + + Assertions.assertEquals("signed-ordinary-alipay-order", result); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeAppPayRequest.class); + verify(alipayClient).sdkExecute(requestCaptor.capture()); + AlipayTradeAppPayRequest request = requestCaptor.getValue(); + AlipayTradeAppPayModel model = (AlipayTradeAppPayModel) request.getBizModel(); + Assertions.assertNull(model.getSubMerchant()); + Assertions.assertNull(model.getSettleInfo()); + Assertions.assertFalse(request.getTextParams().containsKey("app_auth_token")); + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).save(orderCaptor.capture()); + Assertions.assertNull(orderCaptor.getValue().getFzRelationId()); + Assertions.assertEquals(YesNoEnum.NO.getCode(), orderCaptor.getValue().getEnableFz()); + } + /** 非当前默认关系即使已经绑定也不能用于创建新支付,避免内部调用伪造关系主键。 */ @Test void appPayShouldRejectBoundButNonDefaultRelation() throws Exception { @@ -402,6 +428,36 @@ class ThirdAppPlusAliPayServiceImplTest { Assertions.assertTrue(fzOrder.getFzRefundReq().contains("2088000000000001")); } + /** 普通支付宝订单退款不查询关系,也不发送退分账参数。 */ + @Test + void refundShouldOmitRoyaltyParametersForOrdinaryPayment() throws Exception { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + payOrder.setEnableFz(YesNoEnum.NO.getCode()); + payOrder.setFzRelationId(null); + MchPaySettingVo ordinarySetting = new MchPaySettingVo(); + ordinarySetting.setId(paySetting.getId()); + ordinarySetting.setMchId(payDto.getMchId()); + ordinarySetting.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType()); + ordinarySetting.setEnable(YesNoEnum.YES.getCode()); + payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(ordinarySetting)); + 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(78L); + refundDto.setRefundPrice(500); + + service.refund(payOrder, refundDto); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeRefundRequest.class); + verify(alipayClient).execute(requestCaptor.capture()); + AlipayTradeRefundModel model = (AlipayTradeRefundModel) requestCaptor.getValue().getBizModel(); + Assertions.assertNull(model.getRefundRoyaltyParameters()); + verify(mchFzRelationService, never()).getById(any()); + } + /** * 验证退款查询找不到原请求时使用已保存的相同退款号和参数重提,受理后继续等待查询终态。 */ @@ -463,6 +519,67 @@ class ThirdAppPlusAliPayServiceImplTest { verify(alipayClient, never()).execute(any(AlipayTradeRefundRequest.class)); } + /** + * 验证支付宝退款查询已成功且返回退分账明细时,即使明细没有 result_code 也必须推进退分账成功。 + */ + @Test + void fzRefundQueryShouldAcceptSuccessfulRoyaltyWithoutResultCode() throws Exception { + ThirdPayOrderEntity payOrder = refundingOrderWithRequest("refund_1787732361123EqtlyENhdQ"); + ThirdFzOrderEntity fzOrder = pendingFzOrder(payOrder); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_REFUNDING.getType()); + fzOrder.setFzRefundNo(payOrder.getRefundNo()); + RefundRoyaltyResult royalty = new RefundRoyaltyResult(); + royalty.setOriTransOut("zaxpay@qq.com"); + royalty.setRefundAmount("0.01"); + royalty.setRoyaltyType("transfer"); + royalty.setTransIn("2088631724809185"); + royalty.setTransOut("2088580178966130"); + AlipayTradeFastpayRefundQueryResponse response = new AlipayTradeFastpayRefundQueryResponse(); + response.setCode("10000"); + response.setMsg("Success"); + response.setRefundStatus("REFUND_SUCCESS"); + response.setRefundAmount("1.00"); + response.setRefundRoyaltys(List.of(royalty)); + when(alipayClient.execute(any(AlipayTradeFastpayRefundQueryRequest.class))).thenReturn(response); + when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.pollDirectPayLifecycle(fzOrder, payOrder); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdFzOrderEntity.class); + verify(thirdFzOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + ThirdFzOrderEntity update = updateCaptor.getValue(); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType(), update.getStatus()); + Assertions.assertNull(update.getFzRefundError()); + Assertions.assertTrue(update.getFzRefundRes().contains("REFUND_SUCCESS")); + Assertions.assertTrue(update.getFzRefundRes().contains("0.01")); + } + + /** + * 验证人工重试历史误判记录时,可以通过支付宝成功事实把本地退分账失败纠正为成功。 + */ + @Test + void fzRefundQueryShouldCorrectHistoricalFalseFailure() throws Exception { + ThirdPayOrderEntity payOrder = refundingOrderWithRequest("refund_historical_false_failure"); + ThirdFzOrderEntity fzOrder = pendingFzOrder(payOrder); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType()); + fzOrder.setFzRefundNo(payOrder.getRefundNo()); + RefundRoyaltyResult royalty = new RefundRoyaltyResult(); + royalty.setRefundAmount("0.01"); + AlipayTradeFastpayRefundQueryResponse response = new AlipayTradeFastpayRefundQueryResponse(); + response.setCode("10000"); + response.setRefundStatus("REFUND_SUCCESS"); + response.setRefundRoyaltys(List.of(royalty)); + when(alipayClient.execute(any(AlipayTradeFastpayRefundQueryRequest.class))).thenReturn(response); + when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.pollDirectPayLifecycle(fzOrder, payOrder); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdFzOrderEntity.class); + verify(thirdFzOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType(), updateCaptor.getValue().getStatus()); + Assertions.assertNull(updateCaptor.getValue().getFzRefundError()); + } + @Test void precreateShouldSetRequiredParametersAndSaveUnpaidOrder() throws Exception { AlipayTradePrecreateResponse response = successPrecreateResponse("https://qr.alipay.com/test-content"); 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 e7f06866..f46a5de0 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 @@ -142,7 +142,10 @@ class ThirdAppPlusServiceImplOrderCodePayTest { lenient().when(valueOperations.setIfAbsent(any(), any(), any())).thenReturn(true); ThirdMchFzRelationEntity boundRelation = new ThirdMchFzRelationEntity(); boundRelation.setId(9001L); - lenient().when(mchFzRelationService.requireBound(any())).thenReturn(boundRelation); + boundRelation.setChannelSubMchId("channel-sub-merchant-1001"); + lenient().when(mchFzRelationService.findBoundDefault(any())).thenReturn(boundRelation); + lenient().when(mchFzRelationService.createPaymentSettingSnapshot(any(), any())) + .thenAnswer(invocation -> invocation.getArgument(0)); AppPlusPayConfig.AliPayConfig aliPayConfig = new AppPlusPayConfig.AliPayConfig(); aliPayConfig.setAppId("2026000000000001"); @@ -188,7 +191,7 @@ class ThirdAppPlusServiceImplOrderCodePayTest { } @Test - void orderCodePayShouldRejectMissingMerchantAndAliPayConfiguration() { + void orderCodePayShouldRejectMissingMerchant() { mockNoExistingOrder(); when(remoteMchService.getById(88L)).thenReturn(R.ok()); Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request)); @@ -197,10 +200,6 @@ class ThirdAppPlusServiceImplOrderCodePayTest { merchant.setPaySettingList(List.of()); Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request)); - merchant = enabledAliPayMerchant(); - merchant.getPaySettingList().get(0).setSettingJson("{}"); - when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); - Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request)); } @Test @@ -227,12 +226,55 @@ class ThirdAppPlusServiceImplOrderCodePayTest { verify(pollingCoordinator).wakeOrderCodePolling(); } + /** 未配置默认关系时支付宝订单码必须按普通支付创建,不写入分账关系主键。 */ + @Test + void orderCodePayShouldCreateOrdinaryAlipayOrderWithoutDefaultRelation() { + mockNoExistingOrder(); + when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); + when(mchFzRelationService.findBoundDefault(any())).thenReturn(null); + ThirdOrderCodePayVo expected = orderCodePayVo("ordinary-pay-no", "ordinary-qr", LocalDateTime.now().plusMinutes(2)); + when(thirdMiniAppAliPayService.precreate(any(), any(), any())).thenReturn(expected); + + ThirdOrderCodePayVo result = service.orderCodePay(request); + + Assertions.assertSame(expected, result); + ArgumentCaptor payDtoCaptor = ArgumentCaptor.forClass(ThirdAppPlusPayDto.class); + verify(thirdMiniAppAliPayService).precreate(payDtoCaptor.capture(), any(), any()); + Assertions.assertNull(payDtoCaptor.getValue().getFzRelationId()); + verify(mchFzRelationService, never()).createPaymentSettingSnapshot(any(), any()); + } + + /** 关闭分账的微信默认关系仍提供sub_mchid,但订单不得携带分账关系主键。 */ + @Test + void orderCodePayShouldUseWechatRoutingRelationWithoutProfitSharing() { + request.setMiniPlatformType(AppPlusPlatformTypeEnum.WECHAT); + merchant = enabledWechatMerchant(); + mockNoExistingOrder(); + when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); + ThirdMchFzRelationEntity routingRelation = new ThirdMchFzRelationEntity(); + routingRelation.setId(9002L); + routingRelation.setChannelSubMchId("wx-routing-sub-1001"); + routingRelation.setEnableFz(YesNoEnum.NO.getCode()); + when(mchFzRelationService.findBoundDefault(any())).thenReturn(routingRelation); + ThirdOrderCodePayVo expected = orderCodePayVo("wechat-routing-pay-no", "wechat-routing-qr", LocalDateTime.now().plusMinutes(2)); + when(thirdAppPlusWechatNativePayService.precreate(any(), any(), any())).thenReturn(expected); + + ThirdOrderCodePayVo result = service.orderCodePay(request); + + Assertions.assertSame(expected, result); + ArgumentCaptor payDtoCaptor = ArgumentCaptor.forClass(ThirdAppPlusPayDto.class); + verify(thirdAppPlusWechatNativePayService).precreate(payDtoCaptor.capture(), any(), any()); + Assertions.assertNull(payDtoCaptor.getValue().getFzRelationId()); + Assertions.assertEquals("wx-routing-sub-1001", payDtoCaptor.getValue().getSubMchid()); + verify(mchFzRelationService).createPaymentSettingSnapshot(any(), org.mockito.ArgumentMatchers.eq(routingRelation)); + } + /** 支付宝订单码启用平台抽佣但关系未绑定时必须拒付,不能继续调用预下单接口。 */ @Test void orderCodePayShouldRejectCommissionWhenRelationNotBound() { mockNoExistingOrder(); when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); - when(mchFzRelationService.requireBound(any())).thenThrow( + when(mchFzRelationService.findBoundDefault(any())).thenThrow( new IllegalStateException("支付平台分佣关系尚未绑定,请稍后重试")); RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request)); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java index 63dbdad9..f3a06c76 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java @@ -124,6 +124,50 @@ class ThirdMchFzRelationServiceImplTest { verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); } + /** 未配置默认关系表示未启用分账,支付编排应收到空值并继续创建普通支付。 */ + @Test + void findBoundDefaultShouldReturnNullWhenCommissionIsNotConfigured() { + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(null); + + ThirdMchFzRelationEntity result = service.findBoundDefault(wechatSetting(101L, 3D)); + + Assertions.assertNull(result); + verify(taskEnqueuer, never()).ensureRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 默认关系关闭分账时只提供渠道商户路由,不要求正比例或已完成接收方绑定。 */ + @Test + void findBoundDefaultShouldReturnDisabledRoutingRelationWithoutBinding() { + ThirdMchFzRelationEntity routingRelation = pendingWechatRelation(7001L); + routingRelation.setIsDefault(YesNoEnum.YES.getCode()); + routingRelation.setEnableFz(YesNoEnum.NO.getCode()); + routingRelation.setFzRatio(0D); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(routingRelation); + + ThirdMchFzRelationEntity result = service.findBoundDefault(wechatSetting(101L, 3D)); + + Assertions.assertSame(routingRelation, result); + verify(taskEnqueuer, never()).ensureRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 关闭分账的支付宝关系生成普通支付快照,不携带直付通二级商户和授权令牌。 */ + @Test + void createPaymentSettingSnapshotShouldOmitAlipayRelationFieldsWhenDisabled() { + MchPaySettingVo setting = alipaySetting(102L, 5D, "legacy-setting-token"); + ThirdMchFzRelationEntity relation = pendingAlipayRelation(7000L); + relation.setEnableFz(YesNoEnum.NO.getCode()); + + MchPaySettingVo snapshot = service.createPaymentSettingSnapshot(setting, relation); + + MchPaySettingEntity.AliPaySetting channel = JSONUtil.toBean( + snapshot.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + Assertions.assertAll( + () -> Assertions.assertNull(channel.getSubMchid()), + () -> Assertions.assertNull(channel.getAccountKey()), + () -> Assertions.assertNull(channel.getAppAuthToken())); + } + /** 正比例默认关系尚未绑定时支付入口必须拒绝,并只确保稳定任务存在而不重置既有退避。 */ @Test void requireBoundShouldRejectAndEnsureTaskWithoutRearm() { @@ -338,9 +382,111 @@ class ThirdMchFzRelationServiceImplTest { () -> Assertions.assertTrue(outRequestNo.matches("[a-z0-9_]+"))); } - /** - * 删除唯一默认关系时不再强制准备替代关系;已绑定关系转为历史状态,新支付由前置校验阻断。 - */ + /** 新增与历史关系完全相同的配置时应恢复原主键,避免唯一键冲突和重复渠道绑定。 */ + @Test + void saveForManageShouldRestoreIdenticalSupersededRelation() { + MchPaySettingVo setting = wechatSetting(101L, 3D); + ThirdMchFzRelationEntity historical = boundWechatRelation(7013L); + historical.setStatus(ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode()); + historical.setIsDefault(YesNoEnum.NO.getCode()); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.insert(any(ThirdMchFzRelationEntity.class))) + .thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate relation")); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(historical); + when(relationMapper.selectCount(any(Wrapper.class))).thenReturn(1L); + when(relationMapper.update(any(), any())).thenReturn(1); + ThirdMchFzRelationSaveDto dto = manageEditDto(historical); + dto.setId(null); + dto.setReceiverAccount(historical.getReceiverAccount()); + + var result = service.saveForManage(dto); + + Assertions.assertAll( + () -> Assertions.assertEquals(historical.getId(), result.getId()), + () -> Assertions.assertEquals(ThirdMchFzRelationStatusEnum.BOUND.getCode(), result.getStatus()), + () -> Assertions.assertEquals(YesNoEnum.NO.getCode(), result.getIsDefault())); + verify(relationMapper).update(any(), any()); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 管理端直接恢复历史关系时无需重新输入脱敏账号,并应将原关系恢复为默认关系。 */ + @Test + void saveForManageShouldRestoreSupersededRelationAsDefault() { + MchPaySettingVo setting = wechatSetting(101L, 3D); + ThirdMchFzRelationEntity historical = boundWechatRelation(7014L); + historical.setStatus(ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode()); + historical.setIsDefault(YesNoEnum.NO.getCode()); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(historical, historical); + when(relationMapper.update(any(), any())).thenReturn(1); + ThirdMchFzRelationSaveDto dto = manageEditDto(historical); + dto.setIsDefault(YesNoEnum.YES.getCode()); + + var result = service.saveForManage(dto); + + Assertions.assertAll( + () -> Assertions.assertEquals(historical.getId(), result.getId()), + () -> Assertions.assertEquals(ThirdMchFzRelationStatusEnum.BOUND.getCode(), result.getStatus()), + () -> Assertions.assertEquals(YesNoEnum.YES.getCode(), result.getIsDefault())); + verify(relationMapper, times(3)).update(any(), any()); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 新增相同支付宝历史关系时,管理端未提交完整授权令牌也必须保留服务端原值。 */ + @Test + void saveForManageShouldPreserveAlipayTokenWhenRestoringSupersededRelation() { + MchPaySettingVo setting = alipaySetting(102L, 5D, "legacy-setting-token"); + ThirdMchFzRelationEntity historical = pendingAlipayRelation(7015L); + historical.setStatus(ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode()); + historical.setIsDefault(YesNoEnum.NO.getCode()); + historical.setBoundTime(LocalDateTime.now().minusMinutes(1)); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.insert(any(ThirdMchFzRelationEntity.class))) + .thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate relation")); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(historical); + when(relationMapper.selectCount(any(Wrapper.class))).thenReturn(1L); + when(relationMapper.update(any(), any())).thenReturn(1); + ThirdMchFzRelationSaveDto dto = manageEditDto(historical); + dto.setId(null); + dto.setReceiverAccount(historical.getReceiverAccount()); + dto.setAppAuthToken(null); + + var result = service.saveForManage(dto); + + Assertions.assertAll( + () -> Assertions.assertEquals(historical.getId(), result.getId()), + () -> Assertions.assertEquals(ThirdMchFzRelationStatusEnum.BOUND.getCode(), result.getStatus()), + () -> Assertions.assertTrue(result.getAppAuthTokenConfigured())); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 新建关闭分账的关系仅保存支付路由,不创建渠道接收方绑定任务。 */ + @Test + void saveForManageShouldNotEnqueueBindingWhenProfitSharingDisabled() { + MchPaySettingVo setting = wechatSetting(101L, 3D); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.selectCount(any(Wrapper.class))).thenReturn(0L); + ThirdMchFzRelationSaveDto dto = new ThirdMchFzRelationSaveDto(); + dto.setMchId(MCH_ID); + dto.setPaySettingId(setting.getId()); + dto.setChannelSubMchId("wx-sub-1001"); + dto.setFzRatio(0D); + dto.setEnableFz(YesNoEnum.NO.getCode()); + ArgumentCaptor relationCaptor = ArgumentCaptor.forClass(ThirdMchFzRelationEntity.class); + when(relationMapper.insert(relationCaptor.capture())).thenReturn(1); + + var result = service.saveForManage(dto); + + Assertions.assertAll( + () -> Assertions.assertEquals(YesNoEnum.NO.getCode(), result.getEnableFz()), + () -> Assertions.assertEquals(0D, result.getFzRatio()), + () -> Assertions.assertEquals("wx-sub-1001", result.getChannelSubMchId()), + () -> Assertions.assertEquals("", relationCaptor.getValue().getReceiverType()), + () -> Assertions.assertEquals("", relationCaptor.getValue().getReceiverAccount())); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 删除唯一默认关系时不再强制准备替代关系;已绑定关系仅转为历史状态。 */ @Test void removeForManageShouldAllowDeletingDefaultWithoutReplacement() { ThirdMchFzRelationEntity existing = boundWechatRelation(7010L); @@ -402,6 +548,7 @@ class ThirdMchFzRelationServiceImplTest { relation.setReceiverAccount("1900000002"); relation.setReceiverName("平台服务商"); relation.setFzRatio(3D); + relation.setEnableFz(YesNoEnum.YES.getCode()); relation.setChannelRelationType(ReceiverRelationType.SERVICE_PROVIDER.name()); relation.setStatus(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode()); relation.setDelFlag("0"); @@ -432,6 +579,7 @@ class ThirdMchFzRelationServiceImplTest { dto.setReceiverAccount(""); dto.setReceiverName(relation.getReceiverName()); dto.setFzRatio(relation.getFzRatio()); + dto.setEnableFz(relation.getEnableFz()); dto.setChannelRelationType(relation.getChannelRelationType()); dto.setIsDefault(YesNoEnum.NO.getCode()); return dto; @@ -451,6 +599,7 @@ class ThirdMchFzRelationServiceImplTest { relation.setReceiverAccount("2088000000000001"); relation.setReceiverName("平台支付宝账号"); relation.setFzRatio(5D); + relation.setEnableFz(YesNoEnum.YES.getCode()); relation.setAppAuthToken("merchant-auth-token"); relation.setOutRequestNo("alirel_7003"); relation.setStatus(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode()); diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletRechargeService.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletRechargeService.java index 5b181214..9416eb5d 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletRechargeService.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletRechargeService.java @@ -38,6 +38,16 @@ public interface RemoteWalletRechargeService { @PostMapping("/wallet-recharge/refund") R refund(@RequestBody WalletRechargeRefundDto dto); + /** + * 冲正人工审核退款,将已入账金额从原钱包强制扣回;重复请求按钱包退款状态幂等返回。 + * + * @param afterSaleId Hardware 售后单 ID + * @return 钱包退款已冲正时返回 {@code true} + */ + @NoToken + @PostMapping("/wallet-recharge/refund/rollback/{afterSaleId}") + R rollbackRefund(@PathVariable("afterSaleId") Long afterSaleId); + /** * 按售后单 ID 查询已经持久化的退款记录,用于 Wallet 已到账但 Hardware 本地事务回滚后的恢复判断。 * diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeController.java index 2e5d6111..00cbc243 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeController.java @@ -61,6 +61,19 @@ public class WalletRechargeController { return R.ok(walletRechargeService.refund(dto)); } + /** + * 供 Hardware 冲正人工审核退款,实际资金目标与金额只读取 Wallet 已持久化的原退款记录。 + * + * @param afterSaleId Hardware 售后单 ID + * @return 钱包资金已扣回时返回 {@code true} + */ + @Inner + @Operation(summary = "远程调用-冲正人工退款") + @PostMapping("/refund/rollback/{afterSaleId}") + public R rollbackRefund(@PathVariable Long afterSaleId) { + return R.ok(walletRechargeService.rollbackRefund(afterSaleId)); + } + /** * 查询售后单是否已经形成钱包退款记录,仅用于跨服务一致性恢复,不提供管理端修改能力。 * diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeService.java index db549501..8c05c138 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeService.java @@ -80,6 +80,14 @@ public interface WalletRechargeService extends IService { */ WalletRechargeVo refund(WalletRechargeRefundDto dto); + /** + * 冲正已完成的人工钱包退款并强制扣回原到账金额,退款状态用于保证重复调用不重复扣款。 + * + * @param afterSaleId Hardware 售后单 ID + * @return 已完成或已处于冲正状态时返回 {@code true} + */ + Boolean rollbackRefund(Long afterSaleId); + /** * 按 Hardware 售后单 ID 查询钱包退款记录。 * diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java index 5dfae29b..b8ddb7fa 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java @@ -25,6 +25,7 @@ import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeRefundDto; import com.bsd.pjcloud.wallet.api.dto.recharge.app.GuardianWardRechargeCreateDto; import com.bsd.pjcloud.wallet.api.dto.recharge.app.WalletAppRechargeDto; import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; +import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity; import com.bsd.pjcloud.wallet.api.entity.WalletTypeEntity; import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity; import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargePackageEntity; @@ -73,6 +74,9 @@ import java.util.Set; @Service public class WalletRechargeServiceImpl extends ServiceImpl implements WalletRechargeService { + /** 人工退款冲正流水业务号前缀,售后单 ID 保证财务流水可以反查原退款。 */ + private static final String REFUND_ROLLBACK_BIZ_PREFIX = "refund_rollback:"; + @Lazy private final WalletRechargeService walletRechargeService; private final WalletRechargePackageService walletRechargePackageService; @@ -286,7 +290,7 @@ public class WalletRechargeServiceImpl extends ServiceImpl() + .eq(WalletTransactionEntity::getWalletId, walletId) + .eq(WalletTransactionEntity::getBizId, REFUND_ROLLBACK_BIZ_PREFIX + afterSaleId) + .eq(WalletTransactionEntity::getBizType, WalletBizEnum.REFUND.getCode()) + .orderByDesc(WalletTransactionEntity::getId) + .last("limit 1")); + } + /** * 校验幂等退款记录的资金目标和金额,防止错误复用售后单 ID 静默返回另一笔退款。 * 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 f455fafe..58f8c890 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 @@ -4,10 +4,12 @@ import com.bsd.pjcloud.admin.api.feign.RemoteUserFaceService; import com.bsd.pjcloud.admin.api.feign.RemoteUserRelationService; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum; import com.bsd.pjcloud.hard.api.feign.RemoteHardOrderService; import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum; import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeRefundDto; import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; +import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity; import com.bsd.pjcloud.wallet.api.entity.mch.MchAutoRefundConfigEntity; import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity; import com.bsd.pjcloud.wallet.mapper.WalletManageMapper; @@ -24,10 +26,13 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InOrder; import org.mockito.Mock; +import org.mockito.ArgumentCaptor; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DuplicateKeyException; import org.springframework.test.util.ReflectionTestUtils; +import java.time.LocalDateTime; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; @@ -211,6 +216,83 @@ class WalletRechargeServiceImplTest { verify(walletTransactionService, never()).addWalletBalance(anyLong(), anyInt(), anyString(), any(WalletBizEnum.class)); } + /** + * 已完成退款首次冲正时必须强制扣回原金额,并把退款记录切换为未退款状态作为重试幂等依据。 + */ + @Test + void completedRefundRollbackShouldDeductBalanceAndMarkNotRefunded() { + WalletRechargeEntity existing = existingRefund(20L, 100); + when(rechargeMapper.selectOne(any())).thenReturn(existing); + when(walletTransactionService.reduceWalletBalance(20L, 100, "refund_rollback:50", + WalletBizEnum.REFUND, true)).thenReturn(new WalletTransactionEntity()); + when(rechargeMapper.updateById(any(WalletRechargeEntity.class))).thenReturn(1); + + assertEquals(Boolean.TRUE, service.rollbackRefund(50L)); + + verify(walletTransactionService).reduceWalletBalance(20L, 100, "refund_rollback:50", + WalletBizEnum.REFUND, true); + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(WalletRechargeEntity.class); + verify(rechargeMapper).updateById(updateCaptor.capture()); + assertEquals(HardRefundStatusEnum.NONE.getStatus(), updateCaptor.getValue().getRefundStatus()); + } + + /** + * 已冲正退款的重复请求应直接成功,不能再次形成负向钱包流水。 + */ + @Test + void duplicateRefundRollbackShouldNotDeductAgain() { + WalletRechargeEntity existing = existingRefund(20L, 100); + existing.setRefundStatus(HardRefundStatusEnum.NONE.getStatus()); + WalletTransactionEntity rollbackTransaction = new WalletTransactionEntity(); + rollbackTransaction.setPrice(-100); + when(rechargeMapper.selectOne(any())).thenReturn(existing); + when(walletTransactionService.getOne(any())).thenReturn(rollbackTransaction); + + assertEquals(Boolean.TRUE, service.rollbackRefund(50L)); + + verify(walletTransactionService, never()).reduceWalletBalance(anyLong(), anyInt(), anyString(), + any(WalletBizEnum.class), any(Boolean.class)); + verify(rechargeMapper, never()).updateById(any(WalletRechargeEntity.class)); + } + + /** + * 冲正后再次同意退款时应重新入账一次并恢复成功状态,后续重复同意仍由成功状态拦截。 + */ + @Test + void refundedAgainAfterRollbackShouldCreditBalanceAndRestoreSuccessState() { + WalletRechargeEntity existing = existingRefund(20L, 100); + existing.setRefundStatus(HardRefundStatusEnum.NONE.getStatus()); + WalletTransactionEntity rollbackTransaction = new WalletTransactionEntity(); + rollbackTransaction.setPrice(-100); + when(rechargeMapper.selectOne(any())).thenReturn(existing); + when(walletTransactionService.getOne(any())).thenReturn(rollbackTransaction); + when(rechargeMapper.updateById(any(WalletRechargeEntity.class))).thenReturn(1); + + service.refund(refundRequest(100, null)); + + verify(walletTransactionService).addWalletBalance(20L, 100, "50", WalletBizEnum.REFUND); + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(WalletRechargeEntity.class); + verify(rechargeMapper).updateById(updateCaptor.capture()); + assertEquals(HardRefundStatusEnum.SUCCESS.getStatus(), updateCaptor.getValue().getRefundStatus()); + } + + /** + * 历史退款记录即使状态仍为默认值,只要没有负向冲正流水就必须按已到账处理,不能重复增加余额。 + */ + @Test + void historicalDefaultRefundStatusWithoutRollbackTransactionShouldNotCreditAgain() { + WalletRechargeEntity existing = existingRefund(20L, 100); + existing.setRefundStatus(HardRefundStatusEnum.NONE.getStatus()); + when(rechargeMapper.selectOne(any())).thenReturn(existing); + when(walletTransactionService.getOne(any())).thenReturn(null); + + service.refund(refundRequest(100, null)); + + verify(walletTransactionService, never()).addWalletBalance(anyLong(), anyInt(), anyString(), + any(WalletBizEnum.class)); + verify(rechargeMapper, never()).updateById(any(WalletRechargeEntity.class)); + } + /** * 构造固定售后单的退款请求。 * @@ -249,6 +331,8 @@ class WalletRechargeServiceImplTest { entity.setWalletId(walletId); entity.setPayRefundOrderId(50L); entity.setRefundTotalPrice(price); + entity.setRefundStatus(HardRefundStatusEnum.SUCCESS.getStatus()); + entity.setRefundTime(LocalDateTime.of(2026, 8, 27, 10, 0)); return entity; } } From f4ff6bd3a3e2b69ddc4fbb214fe1bebd51af54f5 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 27 Aug 2026 15:36:41 +0800 Subject: [PATCH 11/31] =?UTF-8?q?feat:=E9=80=80=E6=AC=BE=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hard/api/vo/order/HardAfterSaleVo.java | 6 ++ .../order/HardCanteenApptTotalController.java | 34 ++++++++ .../controller/order/HardCarController.java | 11 ++- .../service/HardCanteenApptTotalService.java | 11 +++ .../impl/order/HardAfterSaleServiceImpl.java | 8 ++ .../HardCanteenApptTotalServiceImpl.java | 79 +++++++++++++++++++ 6 files changed, 148 insertions(+), 1 deletion(-) 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 c4a1faac..b67f2060 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 @@ -6,6 +6,7 @@ import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface; import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface; import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface; import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface; +import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleCarEntity; import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity; import com.bsd.pjcloud.hard.api.vo.base.BaseOrderInterface; import lombok.Data; @@ -99,4 +100,9 @@ public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserInte private Integer orderPayPrice; private LocalDateTime orderPayTime; + + /** + * 当前售后单申请时保存的商品快照,用于审批详情展示本次退款商品及份数;历史未保存明细时为空列表。 + */ + private List carList; } 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 b89696d7..2ca224f6 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 @@ -8,11 +8,14 @@ import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; import com.bsd.pjcloud.common.log.annotation.SysLog; +import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; import com.bsd.pjcloud.hard.api.dto.order.HardCanteenUserApptQueryDto; import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; 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.HardCanteenUserApptStatisticsSummaryVo; 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; @@ -91,6 +94,37 @@ public class HardCanteenApptTotalController { return R.ok(canteenApptTotalService.productReservTotal(page, dto, null)); } + /** + * 查询 App 当前可管理食堂范围内的预约菜品及点餐人员统计。 + * + *

食堂主键不信任客户端输入,而是根据当前商户管理员重新计算;服务层只统计有效预约订单, + * 并按已完成退款扣减后的净菜品份数聚合。

+ * + * @param query 预约用餐日期范围和餐段筛选条件 + * @return 当前账号可管理食堂内的菜品和点餐人员汇总;没有可管理食堂时返回空汇总 + */ + @Operation(summary = "App预约菜品人员统计", description = "按当前账号可管理食堂统计预约菜品和点餐人员净份数") + @ApiCryptoAes + @PostMapping("/app/user-reserv-statistics") + public R appUserReservationStatistics( + @Valid @RequestBody HardCanteenUserApptQueryDto query) { + PjcloudUser user = SecurityUtils.getUser(); + if (user == null || user.getId() == null) { + throw new IllegalStateException("未获取到当前登录用户"); + } + R> merchantResponse = remoteMchService + .getSelfMchIdList(user.getId(), List.of(MerchantTypeEnum.CANTEEN)); + if (merchantResponse == null || !merchantResponse.isOk()) { + String message = merchantResponse == null ? null : merchantResponse.getMsg(); + throw new IllegalStateException(ObjectUtil.isEmpty(message) ? "查询可管理食堂范围失败" : message); + } + List merchantIds = merchantResponse.getData(); + if (ObjectUtil.isEmpty(merchantIds)) { + return R.ok(new HardCanteenUserApptStatisticsSummaryVo()); + } + return R.ok(canteenApptTotalService.appUserReservationStatistics(query, merchantIds)); + } + /* ************************************* end app end ************************************* */ /** diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarController.java index bb84f2d4..03c6fdea 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarController.java @@ -136,6 +136,13 @@ public class HardCarController { } /* ************************************* end app end ************************************* */ + /** + * 分页查询订单原购物车商品,并补充已完成退款、申请中退款和当前剩余份数。 + * + * @param page 分页参数 + * @param orderId 原订单主键 + * @return 原购物车商品分页;订单不存在或未关联购物车时返回空数据 + */ @GetMapping("/order-id") @Operation(summary = "根据订单id查询购物车") public R> orderId(@ParameterObject Page page, @RequestParam Long orderId) { @@ -144,7 +151,9 @@ public class HardCarController { if (ObjectUtil.isEmpty(order) || ObjectUtil.isEmpty(order.getParentCarId())) return R.ok(null); HardCarDto dto = new HardCarDto(); dto.setParentCarId(order.getParentCarId()); - return R.ok(hardCarService.pageJoin(page, dto)); + Page carPage = hardCarService.pageJoin(page, dto); + hardAfterSaleCarService.fillRefundQuantityStats(order.getParentCarId(), carPage.getRecords()); + return R.ok(carPage); } @GetMapping("/refund-car") 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 61eba838..adee4d5a 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 @@ -6,6 +6,7 @@ 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.HardCanteenUserApptStatisticsSummaryVo; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptVo; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsVo; @@ -53,4 +54,14 @@ public interface HardCanteenApptTotalService { */ HardCanteenUserApptStatisticsVo userReservationStatistics(HardCanteenUserApptQueryDto query); + /** + * 按 App 当前账号可管理的食堂范围统计有效预约菜品及点餐人员。 + * + * @param query 预约用餐日期范围和餐段筛选条件 + * @param merchantIds 服务端根据当前账号解析出的可管理食堂主键集合 + * @return 已完成退款扣减后的菜品和点餐人员汇总;食堂范围为空时返回空汇总 + */ + HardCanteenUserApptStatisticsSummaryVo appUserReservationStatistics( + HardCanteenUserApptQueryDto query, List merchantIds); + } 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 f811bc7e..e1c1136b 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 @@ -466,6 +466,12 @@ public class HardAfterSaleServiceImpl extends ServiceImpl list = baseMapper.selectList(new LambdaQueryWrapper() @@ -475,6 +481,8 @@ public class HardAfterSaleServiceImpl extends ServiceImpl user = userService.getInnerByUserId(detail.getUserId()); 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 68f75b1b..91ee55f9 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 @@ -299,6 +299,85 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ return result; } + /** + * 按 App 当前账号可管理食堂统计预约菜品及点餐人员。 + * + *

与 PC 人员预约统计不同,App 商户视角以可管理食堂中的有效订单为主数据源, + * 再按订单用户主键批量补齐姓名和部门,避免商户账号的组织数据权限漏掉实际点餐人员。

+ * + * @param query 预约用餐日期范围和餐段筛选条件 + * @param merchantIds 服务端解析出的可管理食堂主键集合,禁止使用客户端自行声明的范围 + * @return 完成退款扣减后的菜品及点餐人员汇总 + */ + @Override + public HardCanteenUserApptStatisticsSummaryVo appUserReservationStatistics( + HardCanteenUserApptQueryDto query, List merchantIds) { + validateQuery(query); + List scopedMerchantIds = merchantIds == null + ? List.of() : merchantIds.stream().filter(Objects::nonNull).distinct().toList(); + if (scopedMerchantIds.isEmpty()) { + return new HardCanteenUserApptStatisticsSummaryVo(); + } + + HardOrderDto orderQuery = buildReservationOrderQuery(query, null); + orderQuery.setMerchantId(null); + orderQuery.setMchIds(scopedMerchantIds); + List orders = retainEffectiveReservationOrders( + hardOrderMapper.scopeList(orderQuery, null), query); + if (orders.isEmpty()) { + return new HardCanteenUserApptStatisticsSummaryVo(); + } + + List userIds = orders.stream().map(HardOrderVo::getUserId) + .filter(Objects::nonNull).distinct().toList(); + List users = fetchUsersByIds(userIds); + List rows = buildRows(users, query, getQueryDates(query), orders).stream() + .filter(row -> row.getOrders() != null && !row.getOrders().isEmpty()).toList(); + Map orderMap = orders.stream().filter(order -> order.getId() != null) + .collect(Collectors.toMap(HardOrderVo::getId, Function.identity(), (left, right) -> left)); + Map> carMap = fetchReservationCars(orders, query); + StatisticsBucket bucket = new StatisticsBucket(); + rows.forEach(row -> bucket.addRow(row, orderMap, carMap)); + return bucket.toSummary(); + } + + /** + * 按订单用户主键批量读取不受商户账号组织数据权限裁剪的用户展示摘要。 + * + *

订单范围已先由可管理食堂限定,因此这里只补齐这些订单关联人员的姓名、账号和部门; + * 若历史用户已删除,则保留一个未命名占位,确保对应订单和菜品份数不会从统计中消失。

+ * + * @param userIds 可管理食堂有效预约订单中的去重用户主键 + * @return 与输入主键顺序一致的用户摘要列表 + */ + private List fetchUsersByIds(List userIds) { + if (userIds.isEmpty()) { + return List.of(); + } + Map userMap = new LinkedHashMap<>(); + for (int start = 0; start < userIds.size(); start += ORDER_USER_BATCH_SIZE) { + int end = Math.min(start + ORDER_USER_BATCH_SIZE, userIds.size()); + List batchUserIds = new ArrayList<>(userIds.subList(start, end)); + R> response = remoteUserService.getUserDeptByIds(batchUserIds); + if (response == null || !response.isOk() || response.getData() == null) { + String message = response == null ? null : response.getMsg(); + throw new IllegalStateException(StrUtil.isBlank(message) ? "查询点餐人员信息失败" : message); + } + response.getData().stream().filter(user -> user.getUserId() != null) + .forEach(user -> userMap.putIfAbsent(user.getUserId(), user)); + } + return userIds.stream().map(userId -> { + UserVO user = userMap.get(userId); + if (user != null) { + return user; + } + UserVO fallback = new UserVO(); + fallback.setUserId(userId); + fallback.setName("未命名用户"); + return fallback; + }).toList(); + } + /** * 校验必填条件和前后端状态协议值,防止未知值被静默当成“全部”。 * From 546f3bc03a31c14a57240ff4fddf3064947ba628 Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Thu, 27 Aug 2026 15:50:47 +0800 Subject: [PATCH 12/31] =?UTF-8?q?=E6=AF=8F=E6=97=A5=E8=8F=9C=E8=B0=B1?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/CanteenDailyMenuImportExcel.java | 32 +- .../entity/CanteenDailyMenuEntity.java | 5 + .../canteen/entity/CanteenSpuEntity.java | 7 + .../pjcloud/canteen/vo/DailyMenuAdminVO.java | 52 +++ .../CanteenDailyMenuController.java | 35 +- .../CanteenDailyMenuTemplateMergeHandler.java | 268 ++++++++++-- .../mapper/CanteenDailyMenuMapper.java | 7 + .../service/CanteenDailyMenuService.java | 6 + .../impl/CanteenDailyMenuImportHelper.java | 412 ++++++++++++++++-- .../impl/CanteenDailyMenuServiceImpl.java | 115 ++++- .../service/impl/CanteenSpuServiceImpl.java | 48 +- .../mapper/CanteenDailyMenuMapper.xml | 33 ++ 12 files changed, 890 insertions(+), 130 deletions(-) create mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuAdminVO.java diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuImportExcel.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuImportExcel.java index 69767655..dfda531c 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuImportExcel.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuImportExcel.java @@ -6,7 +6,8 @@ import lombok.Data; import java.math.BigDecimal; /** - * 每日菜谱规范导入模板行 + * 每日菜谱导入/导出 Excel 行(导出含菜谱 ID,导入时可按 ID 更新) + *

列顺序:日期、餐段、套餐名、菜品名、分类、单价、套餐价、库存、配料、菜谱ID、填写说明 */ @Data public class CanteenDailyMenuImportExcel { @@ -23,18 +24,37 @@ public class CanteenDailyMenuImportExcel { @ExcelProperty(value = "菜品名", index = 3) private String dishName; - @ExcelProperty(value = "配料", index = 4) - private String ingredients; + /** 分类名称;新建菜品时优先按此匹配,匹配不到再用默认分类 */ + @ExcelProperty(value = "分类", index = 4) + private String categoryName; /** 菜品自身单价;不填默认 0 */ - @ExcelProperty(value = "菜品单价(元)", index = 5) + @ExcelProperty(value = "单价(元)", index = 5) private BigDecimal dishPriceYuan; - /** 套餐售价(套餐首行填写,可与套餐名合并);单品可留空,用「菜品单价」 */ + /** 套餐售价(套餐首行填写);单品可留空 */ @ExcelProperty(value = "套餐价(元)", index = 6) private BigDecimal priceYuan; + /** + * 可售库存。单品填本行;套餐填首行(挂在套餐 SPU 上)。 + * 不填:新建默认 9999,已存在则不改。 + */ + @ExcelProperty(value = "库存", index = 7) + private Integer stock; + + @ExcelProperty(value = "配料", index = 8) + private String ingredients; + + /** 每日菜谱记录 ID;导入时有则更新,无则新增 */ + @ExcelProperty(value = "菜谱ID", index = 9) + private Long id; + + /** 关联菜品 SPU ID;导出参考,导入不依赖 */ + @ExcelProperty(value = "菜品SPU ID", index = 10) + private Long spuId; + /** 仅示例说明用,导入逻辑忽略 */ - @ExcelProperty(value = "填写说明", index = 7) + @ExcelProperty(value = "填写说明", index = 11) private String tip; } diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuEntity.java index 2bf372e2..577bd995 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuEntity.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuEntity.java @@ -109,4 +109,9 @@ public class CanteenDailyMenuEntity extends Model { @TableField(exist = false) private List spuIds; + /** 多餐段排菜:优先于 hourType;为空则回退单餐段 hourType */ + @Schema(description = "归属时间列表 1早餐 2午餐 3晚餐") + @TableField(exist = false) + private List hourTypes; + } \ No newline at end of file diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenSpuEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenSpuEntity.java index 15ed0133..b7664650 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenSpuEntity.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenSpuEntity.java @@ -210,6 +210,13 @@ public class CanteenSpuEntity extends Model { @Schema(description="是否显示") private String isShow; + /** + * 列表筛选:餐段(按分类 categoryType 过滤,非表字段) + */ + @TableField(exist = false) + @Schema(description = "餐段筛选 1早餐 2午餐 3晚餐") + private Integer hourType; + /** * 套餐组成(仅 spuType=2 时使用) */ diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuAdminVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuAdminVO.java new file mode 100644 index 00000000..cb03778c --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuAdminVO.java @@ -0,0 +1,52 @@ +package com.bsd.pjcloud.canteen.vo; + +import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO; +import lombok.Data; + +import java.time.LocalDate; +import java.util.List; + +/** + * PC 管理端每日菜谱列表行 + */ +@Data +public class DailyMenuAdminVO { + + private Long id; + + private LocalDate startTime; + + /** 星期文案,如 周四 */ + private String weekdayLabel; + + private Integer hourType; + + private Long merchantId; + + private Long spuId; + + private String name; + + private String picUrl; + + private Long categoryId; + + private String categoryName; + + /** 配料(来自菜品 description) */ + private String ingredients; + + private Integer sort; + + private Integer stock; + + /** 当前剩余可售库存,与 stock 同源 */ + private Integer remainStock; + + private Integer price; + + private Integer spuType; + + private List comboItems; + +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java index c7fff071..9da962dc 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java @@ -86,6 +86,23 @@ public class CanteenDailyMenuController { return dailyMenuImportHelper.templateRows(); } + /** + * 导出某一天的每日菜谱(与导入模板列一致,含菜谱 ID / SPU ID) + */ + @Operation(summary = "导出当日菜谱(可再导入更新)") + @ResponseExcel(name = "每日菜谱", writeHandler = CanteenDailyMenuTemplateMergeHandler.class) + @GetMapping("/import/export") + @PreAuthorize("@pms.hasPermission('canteen_canteenDailyMenu_export')") + public List exportImportExcel(@RequestParam("merchantId") Long merchantId, + @RequestParam("bizDate") String bizDate) { + List rows = dailyMenuImportHelper.exportRows(merchantId, bizDate); + // @ResponseExcel 空 List 不会落盘,导致前端收到空 blob;至少写一行空对象以输出表头 + if (rows == null || rows.isEmpty()) { + return List.of(new CanteenDailyMenuImportExcel()); + } + return rows; + } + /** * 通过id查询每日菜谱 * @@ -149,7 +166,7 @@ public class CanteenDailyMenuController { @PreAuthorize("@pms.hasPermission('canteen_canteenDailyMenu_add')") public R importExcel(@RequestParam("file") MultipartFile file, @RequestParam("merchantId") Long merchantId, - @RequestParam("categoryId") Long categoryId, + @RequestParam(value = "categoryId", required = false) Long categoryId, @RequestParam(value = "replaceDay", defaultValue = "true") boolean replaceDay) throws Exception { try { return dailyMenuImportHelper.importExcel(file, merchantId, categoryId, replaceDay); @@ -228,6 +245,22 @@ public class CanteenDailyMenuController { return R.ok(canteenDailyMenuService.selectTimeFood(time, merchantId, categoryId)); } + /** + * PC 管理端每日菜谱列表(含排序、库存、机器可用、预约/核销/现场购买统计) + */ + @Operation(summary = "管理端每日菜谱列表") + @GetMapping("/admin/list") + @PreAuthorize("@pms.hasPermission('canteen_canteenDailyMenu_view')") + public R> selectAdminList( + @RequestParam("time") String time, + @RequestParam("merchantId") Long merchantId, + @RequestParam(value = "categoryId", required = false) Long categoryId) { + if (ObjectUtil.isEmpty(time)) { + time = DateUtil.today(); + } + return R.ok(canteenDailyMenuService.selectAdminList(time, merchantId, categoryId)); + } + // ---------AD------------ /** diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/excel/CanteenDailyMenuTemplateMergeHandler.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/excel/CanteenDailyMenuTemplateMergeHandler.java index a24bc362..a6700423 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/excel/CanteenDailyMenuTemplateMergeHandler.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/excel/CanteenDailyMenuTemplateMergeHandler.java @@ -4,6 +4,7 @@ import com.alibaba.excel.write.handler.WorkbookWriteHandler; import com.alibaba.excel.write.metadata.holder.WriteWorkbookHolder; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.CellType; import org.apache.poi.ss.usermodel.HorizontalAlignment; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; @@ -11,12 +12,20 @@ import org.apache.poi.ss.usermodel.VerticalAlignment; import org.apache.poi.ss.util.CellRangeAddress; /** - * 每日菜谱导入模板:套餐多行合并日期/餐段/套餐名/套餐价/说明;菜品单价不合并。 - *

列:0日期 1餐段 2套餐名 3菜品名 4配料 5菜品单价 6套餐价 7填写说明 - *

数据行(表头第 0 行):1-3 A套餐,4-5 B套餐,6 午餐单品,7-8 早餐单品 + * 每日菜谱导入模板 / 导出:按数据动态合并日期、餐段、套餐名、套餐价、菜谱ID、说明。 + *

列:0日期 1餐段 2套餐名 3菜品名 4分类 5单价 6套餐价 7库存 8配料 9菜谱ID 10SPU ID 11填写说明 */ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandler { + private static final int COL_DATE = 0; + private static final int COL_MEAL = 1; + private static final int COL_COMBO = 2; + private static final int COL_COMBO_PRICE = 6; + private static final int COL_STOCK = 7; + private static final int COL_ID = 9; + private static final int COL_SPU_ID = 10; + private static final int COL_TIP = 11; + @Override public void afterWorkbookDispose(WriteWorkbookHolder writeWorkbookHolder) { Sheet sheet = writeWorkbookHolder.getWorkbook().getSheetAt(0); @@ -24,52 +33,231 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle return; } - merge(sheet, 1, 8, 0, 0); - merge(sheet, 1, 6, 1, 1); - merge(sheet, 7, 8, 1, 1); - // A 套餐:套餐名、套餐价、填写说明(菜品单价列不合并) - merge(sheet, 1, 3, 2, 2); - merge(sheet, 1, 3, 6, 6); - merge(sheet, 1, 3, 7, 7); - // B 套餐 - merge(sheet, 4, 5, 2, 2); - merge(sheet, 4, 5, 6, 6); - merge(sheet, 4, 5, 7, 7); + int lastDataRow = sheet.getLastRowNum(); + if (lastDataRow < 1) { + setColumnWidths(sheet); + return; + } CellStyle center = writeWorkbookHolder.getWorkbook().createCellStyle(); center.setAlignment(HorizontalAlignment.CENTER); center.setVerticalAlignment(VerticalAlignment.CENTER); - applyStyle(sheet, center, new int[] { 0, 1, 2, 6, 7 }, 1, 8); + mergeByRuns(sheet, center); + setColumnWidths(sheet); + } + + /** + * 向下填充后,按连续相同值合并:日期 / 餐段 / 套餐块(套餐名+套餐价+ID+SPU+说明) + */ + private static void mergeByRuns(Sheet sheet, CellStyle center) { + int lastRow = sheet.getLastRowNum(); + String[] dates = new String[lastRow + 1]; + String[] meals = new String[lastRow + 1]; + String[] combos = new String[lastRow + 1]; + String[] menuIds = new String[lastRow + 1]; + boolean[] comboGroup = new boolean[lastRow + 1]; + + String lastDate = null; + String lastMeal = null; + String lastCombo = null; + String lastMenuId = null; + boolean inCombo = false; + + for (int r = 1; r <= lastRow; r++) { + String date = cellText(sheet, r, COL_DATE); + String meal = cellText(sheet, r, COL_MEAL); + String combo = cellText(sheet, r, COL_COMBO); + String menuId = cellText(sheet, r, COL_ID); + + if (isBlank(date)) { + date = lastDate; + } + else { + lastDate = date; + } + if (isBlank(meal)) { + meal = lastMeal; + } + else { + lastMeal = meal; + } + + if (!isBlank(combo)) { + if (isSingleCombo(combo)) { + inCombo = false; + lastCombo = null; + lastMenuId = null; + combo = "无"; + } + else { + inCombo = true; + lastCombo = combo; + if (!isBlank(menuId)) { + lastMenuId = menuId; + } + } + } + else if (inCombo) { + combo = lastCombo; + if (isBlank(menuId)) { + menuId = lastMenuId; + } + else { + // 新菜谱 ID 出现,视为新套餐组起点 + lastMenuId = menuId; + } + } + else { + combo = "无"; + lastMenuId = null; + } + + dates[r] = date; + meals[r] = meal; + combos[r] = combo; + menuIds[r] = menuId; + comboGroup[r] = inCombo && !isSingleCombo(combo); + } + + mergeEqualRuns(sheet, center, lastRow, dates, COL_DATE, null, null); + mergeEqualRuns(sheet, center, lastRow, meals, COL_MEAL, dates, null); + + int runStart = 1; + for (int r = 2; r <= lastRow + 1; r++) { + boolean same = r <= lastRow + && comboGroup[runStart] + && comboGroup[r] + && eq(dates[r], dates[runStart]) + && eq(meals[r], meals[runStart]) + && eq(combos[r], combos[runStart]) + && eq(menuIds[r], menuIds[runStart]); + if (!same) { + if (comboGroup[runStart] && runStart <= lastRow) { + int endRow = Math.min(r - 1, lastRow); + mergeRange(sheet, runStart, endRow, COL_COMBO, center); + mergeRange(sheet, runStart, endRow, COL_COMBO_PRICE, center); + mergeRange(sheet, runStart, endRow, COL_STOCK, center); + mergeRange(sheet, runStart, endRow, COL_ID, center); + mergeRange(sheet, runStart, endRow, COL_SPU_ID, center); + mergeRange(sheet, runStart, endRow, COL_TIP, center); + } + runStart = r; + } + } + } + + private static void mergeEqualRuns(Sheet sheet, CellStyle center, int lastRow, String[] values, int col, + String[] key1, String[] key2) { + int runStart = 1; + for (int r = 2; r <= lastRow + 1; r++) { + boolean same = r <= lastRow + && !isBlank(values[runStart]) + && eq(values[r], values[runStart]) + && (key1 == null || eq(key1[r], key1[runStart])) + && (key2 == null || eq(key2[r], key2[runStart])); + if (!same) { + mergeRange(sheet, runStart, Math.min(r - 1, lastRow), col, center); + runStart = r; + } + } + } + + private static void mergeRange(Sheet sheet, int firstRow, int lastRow, int col, CellStyle center) { + if (firstRow > lastRow) { + return; + } + if (firstRow == lastRow) { + Cell cell = getCell(sheet, firstRow, col); + if (cell != null) { + cell.setCellStyle(center); + } + return; + } + sheet.addMergedRegion(new CellRangeAddress(firstRow, lastRow, col, col)); + for (int r = firstRow; r <= lastRow; r++) { + Cell cell = getCell(sheet, r, col); + if (cell != null) { + cell.setCellStyle(center); + } + } + } + + private static Cell getCell(Sheet sheet, int rowIdx, int col) { + Row row = sheet.getRow(rowIdx); + return row == null ? null : row.getCell(col); + } + + private static String cellText(Sheet sheet, int rowIdx, int col) { + Cell cell = getCell(sheet, rowIdx, col); + if (cell == null) { + return null; + } + CellType type = cell.getCellType(); + if (type == CellType.STRING) { + return trimToNull(cell.getStringCellValue()); + } + if (type == CellType.NUMERIC) { + double n = cell.getNumericCellValue(); + if (n == Math.rint(n)) { + return String.valueOf((long) n); + } + return String.valueOf(n); + } + if (type == CellType.BOOLEAN) { + return String.valueOf(cell.getBooleanCellValue()); + } + if (type == CellType.FORMULA) { + try { + return trimToNull(cell.getStringCellValue()); + } + catch (Exception e) { + return null; + } + } + return null; + } + + private static String trimToNull(String s) { + if (s == null) { + return null; + } + String t = s.trim(); + return t.isEmpty() ? null : t; + } + + private static boolean isBlank(String s) { + return s == null || s.isBlank(); + } + + private static boolean eq(String a, String b) { + if (a == null) { + return b == null; + } + return a.equals(b); + } + + private static boolean isSingleCombo(String combo) { + return isBlank(combo) + || "无".equals(combo) + || "-".equals(combo) + || "单品".equals(combo) + || "(无)".equals(combo) + || "(无)".equals(combo); + } + + private static void setColumnWidths(Sheet sheet) { sheet.setColumnWidth(0, 12 * 256); sheet.setColumnWidth(1, 8 * 256); sheet.setColumnWidth(2, 10 * 256); sheet.setColumnWidth(3, 16 * 256); - sheet.setColumnWidth(4, 22 * 256); - sheet.setColumnWidth(5, 14 * 256); + sheet.setColumnWidth(4, 12 * 256); + sheet.setColumnWidth(5, 12 * 256); sheet.setColumnWidth(6, 12 * 256); - sheet.setColumnWidth(7, 48 * 256); - } - - private static void merge(Sheet sheet, int firstRow, int lastRow, int firstCol, int lastCol) { - if (firstRow == lastRow && firstCol == lastCol) { - return; - } - sheet.addMergedRegion(new CellRangeAddress(firstRow, lastRow, firstCol, lastCol)); - } - - private static void applyStyle(Sheet sheet, CellStyle style, int[] cols, int firstRow, int lastRow) { - for (int r = firstRow; r <= lastRow; r++) { - Row row = sheet.getRow(r); - if (row == null) { - continue; - } - for (int c : cols) { - Cell cell = row.getCell(c); - if (cell != null) { - cell.setCellStyle(style); - } - } - } + sheet.setColumnWidth(7, 10 * 256); + sheet.setColumnWidth(8, 22 * 256); + sheet.setColumnWidth(9, 20 * 256); + sheet.setColumnWidth(10, 20 * 256); + sheet.setColumnWidth(11, 48 * 256); } } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java index bbc60784..215f7cd2 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java @@ -2,6 +2,7 @@ package com.bsd.pjcloud.canteen.mapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO; +import com.bsd.pjcloud.canteen.vo.DailyMenuAdminVO; import com.bsd.pjcloud.canteen.vo.DailyMenuVO; import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO; import com.bsd.pjcloud.common.data.datascope.DataScope; @@ -44,4 +45,10 @@ public interface CanteenDailyMenuMapper extends PjcloudBaseMapper selectAdminList(@Param("time") String time, @Param("merchantId") Long merchantId, + @Param("categoryId") Long categoryId, DataScope dataScope); } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java index 839ad25c..8e5a7f5c 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java @@ -6,6 +6,7 @@ import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuCVDTO; import com.bsd.pjcloud.canteen.dto.DailyMenuAll; import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO; import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity; +import com.bsd.pjcloud.canteen.vo.DailyMenuAdminVO; import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.security.service.PjcloudUser; @@ -69,4 +70,9 @@ public interface CanteenDailyMenuService extends IService selectAdminList(String time, Long merchantId, Long categoryId); } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuImportHelper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuImportHelper.java index bdcdbfa9..1680ddf4 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuImportHelper.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuImportHelper.java @@ -5,12 +5,16 @@ import cn.hutool.core.util.StrUtil; import com.alibaba.excel.EasyExcel; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuImportExcel; +import com.bsd.pjcloud.canteen.entity.CanteenCategoryEntity; import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity; import com.bsd.pjcloud.canteen.entity.CanteenSpuComboItemEntity; import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity; +import com.bsd.pjcloud.canteen.mapper.CanteenCategoryMapper; import com.bsd.pjcloud.canteen.service.CanteenDailyMenuService; import com.bsd.pjcloud.canteen.service.CanteenSpuService; import com.bsd.pjcloud.canteen.util.CanteenMerchantScopeHelper; +import com.bsd.pjcloud.canteen.vo.DailyMenuAdminVO; +import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO; import com.bsd.pjcloud.common.core.util.R; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -24,10 +28,12 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.ArrayList; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; /** @@ -42,34 +48,33 @@ public class CanteenDailyMenuImportHelper { private final CanteenSpuService canteenSpuService; private final CanteenDailyMenuService canteenDailyMenuService; private final CanteenMerchantScopeHelper merchantScopeHelper; + private final CanteenCategoryMapper canteenCategoryMapper; public List templateRows() { List list = new ArrayList<>(); - // 午餐 A 套餐:套餐价 21;各菜可有自己的菜品单价 - list.add(row("2026-08-17", "午餐", "A套餐", "糖醋排骨", "青莲仔排", "12", "21", - "套餐价只填首行;菜品单价每行可填,不填默认0。套餐名/菜品名在菜品列表已存在则复用,不存在则自动新增")); - list.add(row(null, null, null, "蒜苔炒腊肉", "蒜苔,腊肉,胡萝卜", "8", null, null)); - list.add(row(null, null, null, "清炒时蔬", "空心菜", "3", null, null)); - // 午餐 B 套餐 - list.add(row(null, null, "B套餐", "红烧肉", "五花肉", "10", "18", + list.add(row("2026-08-17", "午餐", "A套餐", "糖醋排骨", "荤菜", "青莲仔排", "12", "21", "100", + "分类按名称匹配;套餐价/库存只填首行。已有菜品不选默认分类也可导入")); + list.add(row(null, null, null, "蒜苔炒腊肉", "荤菜", "蒜苔,腊肉,胡萝卜", "8", null, null, null)); + list.add(row(null, null, null, "清炒时蔬", "素菜", "空心菜", "3", null, null, null)); + list.add(row(null, null, "B套餐", "红烧肉", "荤菜", "五花肉", "10", "18", "80", "换套餐名即另一套;名称已存在不新增")); - list.add(row(null, null, null, "土豆丝", "土豆", "4", null, null)); - // 单品:套餐名「无」,单价写在菜品单价 - list.add(row(null, null, "无", "千张大骨汤", null, "0", null, + list.add(row(null, null, null, "土豆丝", "素菜", "土豆", "4", null, null, null)); + list.add(row(null, null, "无", "千张大骨汤", "汤类", null, "0", null, "50", "单品:套餐名填「无」;菜品已存在则复用")); - list.add(row(null, "早餐", "无", "榨菜肉丝面", "榨菜,肉丝", "6.4", null, + list.add(row(null, "早餐", "无", "榨菜肉丝面", "面食", "榨菜,肉丝", "6.4", null, "30", "早餐单品")); - list.add(row(null, null, "无", "豆浆", null, "2", null, null)); + list.add(row(null, null, "无", "豆浆", "饮品", null, "2", null, "100", null)); return list; } private static CanteenDailyMenuImportExcel row(String date, String meal, String combo, String dish, - String ingredients, String dishPrice, String comboPrice, String tip) { + String categoryName, String ingredients, String dishPrice, String comboPrice, String stock, String tip) { CanteenDailyMenuImportExcel r = new CanteenDailyMenuImportExcel(); r.setBizDate(date); r.setMealName(meal); r.setComboName(combo); r.setDishName(dish); + r.setCategoryName(categoryName); r.setIngredients(ingredients); if (StrUtil.isNotBlank(dishPrice)) { r.setDishPriceYuan(new BigDecimal(dishPrice)); @@ -77,19 +82,111 @@ public class CanteenDailyMenuImportHelper { if (StrUtil.isNotBlank(comboPrice)) { r.setPriceYuan(new BigDecimal(comboPrice)); } + if (StrUtil.isNotBlank(stock)) { + r.setStock(Integer.valueOf(stock)); + } r.setTip(tip); return r; } + /** + * 导出指定日期的每日菜谱(与导入模板列一致,含菜谱 ID) + */ + public List exportRows(Long merchantId, String bizDate) { + if (merchantId == null || StrUtil.isBlank(bizDate)) { + return List.of(); + } + LocalDate date = parseDate(bizDate.trim()); + if (date == null) { + return List.of(); + } + List menus = canteenDailyMenuService.selectAdminList(date.format(DATE_FMT), merchantId, null); + if (CollUtil.isEmpty(menus)) { + return List.of(); + } + List rows = new ArrayList<>(); + for (DailyMenuAdminVO menu : menus) { + rows.addAll(toExportRows(menu)); + } + return rows; + } + + private List toExportRows(DailyMenuAdminVO menu) { + List rows = new ArrayList<>(); + String dateStr = menu.getStartTime() != null ? menu.getStartTime().format(DATE_FMT) : ""; + String meal = formatMeal(menu.getHourType()); + boolean isCombo = menu.getSpuType() != null && menu.getSpuType() == 2 + && CollUtil.isNotEmpty(menu.getComboItems()); + if (isCombo) { + String comboLabel = resolveComboLabel(menu); + BigDecimal comboPrice = fenToYuan(menu.getPrice()); + List items = menu.getComboItems(); + for (int i = 0; i < items.size(); i++) { + OnlineSpuComboItemVO item = items.get(i); + CanteenDailyMenuImportExcel row = new CanteenDailyMenuImportExcel(); + if (i == 0) { + row.setId(menu.getId()); + row.setSpuId(menu.getSpuId()); + row.setBizDate(dateStr); + row.setMealName(meal); + row.setComboName(comboLabel); + row.setPriceYuan(comboPrice); + row.setStock(menu.getStock()); + row.setCategoryName(menu.getCategoryName()); + } + row.setDishName(StrUtil.blankToDefault(item.getName(), "")); + row.setDishPriceYuan(fenToYuan(item.getPrice())); + rows.add(row); + } + } + else { + CanteenDailyMenuImportExcel row = new CanteenDailyMenuImportExcel(); + row.setId(menu.getId()); + row.setSpuId(menu.getSpuId()); + row.setBizDate(dateStr); + row.setMealName(meal); + row.setComboName("无"); + row.setDishName(menu.getName()); + row.setCategoryName(menu.getCategoryName()); + row.setDishPriceYuan(fenToYuan(menu.getPrice())); + row.setStock(menu.getStock()); + row.setIngredients(menu.getIngredients()); + rows.add(row); + } + return rows; + } + + private static String resolveComboLabel(DailyMenuAdminVO menu) { + if (StrUtil.isNotBlank(menu.getName()) && menu.getName().contains("-")) { + return menu.getName().substring(0, menu.getName().indexOf("-")); + } + return menu.getName(); + } + + private static String formatMeal(Integer hourType) { + if (hourType == null) { + return ""; + } + return switch (hourType) { + case 1 -> "早餐"; + case 2 -> "午餐"; + default -> "晚餐"; + }; + } + + private static BigDecimal fenToYuan(Integer fen) { + if (fen == null) { + return null; + } + return BigDecimal.valueOf(fen).divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP); + } + @Transactional(rollbackFor = Exception.class) public R importExcel(MultipartFile file, Long merchantId, Long categoryId, boolean replaceDay) throws IOException { if (merchantId == null) { return R.failed("请选择商户"); } - if (categoryId == null) { - return R.failed("请选择新建菜品默认分类"); - } if (file == null || file.isEmpty()) { return R.failed("请上传 Excel 文件"); } @@ -104,16 +201,25 @@ public class CanteenDailyMenuImportHelper { normalizeMergedCells(rows); Long deptId = merchantScopeHelper.requireDeptIdByMerchantId(merchantId); + Map categoryMap = loadCategoryMap(merchantId); - // groupKey = date|hourType|comboOrEmpty - Map> groups = new LinkedHashMap<>(); List errors = new ArrayList<>(); - int line = 1; + List dataRows = new ArrayList<>(); for (CanteenDailyMenuImportExcel row : rows) { - line++; if (isBlankRow(row)) { continue; } + dataRows.add(row); + } + if (CollUtil.isEmpty(dataRows)) { + return R.failed("导入数据为空"); + } + + // groupKey = date|hourType|comboOrEmpty|menuIdOrNew + Map> groups = new LinkedHashMap<>(); + int line = 1; + for (CanteenDailyMenuImportExcel row : dataRows) { + line++; Integer hourType = parseMeal(row.getMealName()); LocalDate date = parseDate(row.getBizDate()); String dish = StrUtil.trim(row.getDishName()); @@ -130,39 +236,72 @@ public class CanteenDailyMenuImportHelper { continue; } String combo = StrUtil.trim(row.getComboName()); - String key = date + "|" + hourType + "|" + StrUtil.blankToDefault(combo, ""); - groups.computeIfAbsent(key, k -> new ArrayList<>()).add(row); + String comboKey = StrUtil.blankToDefault(combo, ""); + Long menuId = row.getId(); + String groupKey; + if (menuId != null) { + groupKey = "id:" + menuId; + } + else if (StrUtil.isNotBlank(comboKey) && !isSingleComboMarker(comboKey)) { + groupKey = "new:" + date + "|" + hourType + "|" + comboKey; + } + else { + groupKey = "new:" + date + "|" + hourType + "|" + dish; + } + groups.computeIfAbsent(groupKey, k -> new ArrayList<>()).add(row); } if (CollUtil.isNotEmpty(errors)) { return R.failed(String.join(";", errors.subList(0, Math.min(errors.size(), 20)))); } Set clearedDayHour = new LinkedHashSet<>(); + int updated = 0; int createdDish = 0; int createdCombo = 0; int scheduled = 0; for (Map.Entry> e : groups.entrySet()) { - String[] parts = e.getKey().split("\\|", -1); - LocalDate date = LocalDate.parse(parts[0]); - Integer hourType = Integer.valueOf(parts[1]); - String comboName = parts[2]; List items = e.getValue(); + Long menuId = items.stream().map(CanteenDailyMenuImportExcel::getId).filter(Objects::nonNull).findFirst() + .orElse(null); + if (menuId != null) { + String err = upsertGroupById(menuId, items, merchantId, deptId, categoryId, categoryMap); + if (err != null) { + errors.add(err); + } + else { + updated++; + } + continue; + } - if (StrUtil.isBlank(comboName)) { + CanteenDailyMenuImportExcel first = items.get(0); + LocalDate date = parseDate(first.getBizDate()); + Integer hourType = parseMeal(first.getMealName()); + String comboName = StrUtil.trim(first.getComboName()); + + if (StrUtil.isBlank(comboName) || isSingleComboMarker(comboName)) { for (CanteenDailyMenuImportExcel item : items) { - FindOrCreate r = findOrCreateDish(merchantId, deptId, categoryId, item.getDishName(), - item.getIngredients(), resolveDishPriceFen(item)); - if (r.created) { - createdDish++; + try { + FindOrCreate r = findOrCreateDish(merchantId, deptId, + resolveCategoryId(item.getCategoryName(), categoryId, categoryMap), + item.getDishName(), item.getIngredients(), resolveDishPriceFen(item), item.getStock()); + if (r.created) { + createdDish++; + } + schedule(merchantId, deptId, date, hourType, r.spu.getId(), replaceDay, clearedDayHour); + scheduled++; + } + catch (IllegalArgumentException ex) { + errors.add(ex.getMessage()); } - schedule(merchantId, deptId, date, hourType, r.spu.getId(), replaceDay, clearedDayHour); - scheduled++; } } else { List children = new ArrayList<>(); Integer comboPrice = null; + Integer comboStock = first.getStock(); + boolean childFailed = false; for (int i = 0; i < items.size(); i++) { CanteenDailyMenuImportExcel item = items.get(i); if (i == 0) { @@ -171,29 +310,136 @@ public class CanteenDailyMenuImportHelper { comboPrice = fen; } } - FindOrCreate r = findOrCreateDish(merchantId, deptId, categoryId, item.getDishName(), - item.getIngredients(), resolveDishPriceFen(item)); - if (r.created) { - createdDish++; + try { + FindOrCreate r = findOrCreateDish(merchantId, deptId, + resolveCategoryId(item.getCategoryName(), categoryId, categoryMap), + item.getDishName(), item.getIngredients(), resolveDishPriceFen(item), null); + if (r.created) { + createdDish++; + } + children.add(r.spu); } - children.add(r.spu); + catch (IllegalArgumentException ex) { + errors.add(ex.getMessage()); + childFailed = true; + break; + } + } + if (childFailed) { + continue; } if (comboPrice == null) { comboPrice = 0; } String mainName = children.get(0).getName(); String fullComboName = comboName + "-" + mainName; - FindOrCreate combo = findOrCreateCombo(merchantId, deptId, categoryId, fullComboName, comboName, - comboPrice, children); - if (combo.created) { - createdCombo++; + try { + FindOrCreate combo = findOrCreateCombo(merchantId, deptId, + resolveCategoryId(first.getCategoryName(), categoryId, categoryMap), + fullComboName, comboName, comboPrice, comboStock, children); + if (combo.created) { + createdCombo++; + } + schedule(merchantId, deptId, date, hourType, combo.spu.getId(), replaceDay, clearedDayHour); + scheduled++; + } + catch (IllegalArgumentException ex) { + errors.add(ex.getMessage()); } - schedule(merchantId, deptId, date, hourType, combo.spu.getId(), replaceDay, clearedDayHour); - scheduled++; } } + if (CollUtil.isNotEmpty(errors)) { + return R.failed(String.join(";", errors.subList(0, Math.min(errors.size(), 20)))); + } - return R.ok(null, String.format("导入成功:新建单品 %d,新建套餐 %d,排菜 %d", createdDish, createdCombo, scheduled)); + return R.ok(null, String.format("导入成功:更新 %d,新建单品 %d,新建套餐 %d,新增排菜 %d", + updated, createdDish, createdCombo, scheduled)); + } + + /** + * 按菜谱 ID 更新一组行(单品一行,套餐多行) + */ + private String upsertGroupById(Long menuId, List items, Long merchantId, Long deptId, + Long defaultCategoryId, Map categoryMap) { + CanteenDailyMenuEntity menu = canteenDailyMenuService.getById(menuId); + if (menu == null) { + return "菜谱ID " + menuId + " 不存在"; + } + if (!merchantId.equals(menu.getMerchantId())) { + return "菜谱ID " + menuId + " 不属于当前商户"; + } + CanteenDailyMenuImportExcel first = items.get(0); + LocalDate date = parseDate(first.getBizDate()); + Integer hourType = parseMeal(first.getMealName()); + if (date == null) { + return "菜谱ID " + menuId + ":日期格式应为 yyyy-MM-dd"; + } + if (hourType == null) { + return "菜谱ID " + menuId + ":餐段应为 早餐/午餐/晚餐"; + } + String comboName = StrUtil.trim(first.getComboName()); + try { + if (StrUtil.isBlank(comboName) || isSingleComboMarker(comboName)) { + return upsertSingleById(menu, first, merchantId, deptId, defaultCategoryId, categoryMap, date, hourType); + } + return upsertComboGroupById(menu, items, merchantId, deptId, defaultCategoryId, categoryMap, comboName, date, + hourType); + } + catch (IllegalArgumentException ex) { + return ex.getMessage(); + } + } + + private String upsertSingleById(CanteenDailyMenuEntity menu, CanteenDailyMenuImportExcel row, Long merchantId, + Long deptId, Long defaultCategoryId, Map categoryMap, LocalDate date, Integer hourType) { + String dish = StrUtil.trim(row.getDishName()); + if (StrUtil.isBlank(dish)) { + return "菜谱ID " + menu.getId() + ":菜品名不能为空"; + } + FindOrCreate created = findOrCreateDish(merchantId, deptId, + resolveCategoryId(row.getCategoryName(), defaultCategoryId, categoryMap), dish, row.getIngredients(), + resolveDishPriceFen(row), row.getStock()); + menu.setStartTime(date); + menu.setHourType(hourType); + menu.setSpuId(created.spu.getId()); + canteenDailyMenuService.updateById(menu); + return null; + } + + private String upsertComboGroupById(CanteenDailyMenuEntity menu, List items, + Long merchantId, Long deptId, Long defaultCategoryId, Map categoryMap, String comboName, + LocalDate date, Integer hourType) { + List children = new ArrayList<>(); + Integer comboPrice = null; + Integer comboStock = items.get(0).getStock(); + for (int i = 0; i < items.size(); i++) { + CanteenDailyMenuImportExcel item = items.get(i); + if (i == 0) { + comboPrice = toFen(item.getPriceYuan()); + } + String dish = StrUtil.trim(item.getDishName()); + if (StrUtil.isBlank(dish)) { + return "菜谱ID " + menu.getId() + ":套餐组成菜品名不能为空"; + } + FindOrCreate r = findOrCreateDish(merchantId, deptId, + resolveCategoryId(item.getCategoryName(), defaultCategoryId, categoryMap), dish, + item.getIngredients(), resolveDishPriceFen(item), null); + children.add(r.spu); + } + if (comboPrice == null) { + CanteenSpuEntity existCombo = canteenSpuService.getById(menu.getSpuId()); + comboPrice = existCombo != null && existCombo.getPrice() != null ? existCombo.getPrice() : 0; + } + String mainName = children.get(0).getName(); + String fullComboName = comboName + "-" + mainName; + FindOrCreate combo = findOrCreateCombo(merchantId, deptId, + resolveCategoryId(items.get(0).getCategoryName(), defaultCategoryId, categoryMap), fullComboName, + comboName, comboPrice, comboStock, children); + menu.setStartTime(date); + menu.setHourType(hourType); + menu.setSpuId(combo.spu.getId()); + canteenDailyMenuService.updateById(menu); + return null; } private void schedule(Long merchantId, Long deptId, LocalDate date, Integer hourType, Long spuId, @@ -224,7 +470,7 @@ public class CanteenDailyMenuImportHelper { } private FindOrCreate findOrCreateDish(Long merchantId, Long deptId, Long categoryId, String name, - String ingredients, Integer priceFen) { + String ingredients, Integer priceFen, Integer stock) { String clean = cleanDishName(name); CanteenSpuEntity exist = canteenSpuService.getOne(Wrappers.lambdaQuery() .eq(CanteenSpuEntity::getMerchantId, merchantId) @@ -242,21 +488,31 @@ public class CanteenDailyMenuImportHelper { exist.setMarketPrice(priceFen); needUpdate = true; } + if (stock != null && stock >= 0 && !Objects.equals(exist.getStock(), stock)) { + exist.setStock(stock); + needUpdate = true; + } if (needUpdate) { canteenSpuService.updateById(exist); } return new FindOrCreate(exist, false); } + if (categoryId == null) { + throw new IllegalArgumentException("新建菜品「" + clean + "」缺少分类:请在 Excel 填写分类名,或选择默认分类"); + } CanteenSpuEntity spu = newBaseSpu(merchantId, deptId, categoryId, clean, 1); spu.setDescription(StrUtil.trim(ingredients)); spu.setPrice(priceFen == null ? 0 : priceFen); spu.setMarketPrice(spu.getPrice()); + if (stock != null && stock >= 0) { + spu.setStock(stock); + } canteenSpuService.save(spu); return new FindOrCreate(spu, true); } private FindOrCreate findOrCreateCombo(Long merchantId, Long deptId, Long categoryId, String fullName, - String pkgLabel, Integer priceFen, List children) { + String pkgLabel, Integer priceFen, Integer stock, List children) { CanteenSpuEntity exist = canteenSpuService.getOne(Wrappers.lambdaQuery() .eq(CanteenSpuEntity::getMerchantId, merchantId) .eq(CanteenSpuEntity::getName, fullName) @@ -274,19 +530,56 @@ public class CanteenDailyMenuImportHelper { if (exist != null) { exist.setPrice(priceFen); exist.setMarketPrice(priceFen); + if (stock != null && stock >= 0) { + exist.setStock(stock); + } exist.setComboItems(items); canteenSpuService.updateDeep(exist); return new FindOrCreate(exist, false); } + if (categoryId == null) { + throw new IllegalArgumentException("新建套餐「" + fullName + "」缺少分类:请在 Excel 填写分类名,或选择默认分类"); + } CanteenSpuEntity combo = newBaseSpu(merchantId, deptId, categoryId, fullName, 2); combo.setIntroduction(pkgLabel); combo.setPrice(priceFen); combo.setMarketPrice(priceFen); + if (stock != null && stock >= 0) { + combo.setStock(stock); + } combo.setComboItems(items); canteenSpuService.saveDeep(combo); return new FindOrCreate(combo, true); } + private Map loadCategoryMap(Long merchantId) { + List list = canteenCategoryMapper.selectList(Wrappers.lambdaQuery() + .eq(CanteenCategoryEntity::getMerchantId, merchantId)); + Map map = new HashMap<>(); + if (CollUtil.isEmpty(list)) { + return map; + } + for (CanteenCategoryEntity c : list) { + if (c != null && StrUtil.isNotBlank(c.getName()) && c.getId() != null) { + map.put(c.getName().trim(), c.getId()); + } + } + return map; + } + + /** + * Excel 分类名优先;匹配不到再用默认分类(可为 null) + */ + private static Long resolveCategoryId(String categoryName, Long defaultCategoryId, Map categoryMap) { + if (StrUtil.isNotBlank(categoryName) && categoryMap != null) { + Long id = categoryMap.get(categoryName.trim()); + if (id != null) { + return id; + } + } + return defaultCategoryId; + } + private CanteenSpuEntity newBaseSpu(Long merchantId, Long deptId, Long categoryId, String name, int spuType) { CanteenSpuEntity spu = new CanteenSpuEntity(); spu.setName(name); @@ -309,7 +602,8 @@ public class CanteenDailyMenuImportHelper { } private static boolean isBlankRow(CanteenDailyMenuImportExcel row) { - return StrUtil.isBlank(row.getBizDate()) && StrUtil.isBlank(row.getDishName()) + return row.getId() == null + && StrUtil.isBlank(row.getBizDate()) && StrUtil.isBlank(row.getDishName()) && StrUtil.isBlank(row.getMealName()) && StrUtil.isBlank(row.getComboName()); } @@ -321,6 +615,8 @@ public class CanteenDailyMenuImportHelper { String lastDate = null; String lastMeal = null; String lastCombo = null; + Long lastMenuId = null; + Long lastSpuId = null; for (CanteenDailyMenuImportExcel row : rows) { if (isBlankRow(row)) { continue; @@ -342,15 +638,35 @@ public class CanteenDailyMenuImportHelper { String combo = StrUtil.trim(row.getComboName()); if (StrUtil.isBlank(combo)) { row.setComboName(lastCombo); + combo = lastCombo; } else if (isSingleComboMarker(combo)) { row.setComboName(null); lastCombo = null; + lastMenuId = null; + lastSpuId = null; + combo = null; } else { + if (!combo.equals(lastCombo)) { + lastMenuId = null; + lastSpuId = null; + } lastCombo = combo; row.setComboName(combo); } + if (row.getId() != null) { + lastMenuId = row.getId(); + if (row.getSpuId() != null) { + lastSpuId = row.getSpuId(); + } + } + else if (lastMenuId != null && StrUtil.isNotBlank(lastCombo)) { + row.setId(lastMenuId); + if (lastSpuId != null) { + row.setSpuId(lastSpuId); + } + } } } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java index 37edcf84..9440ae8e 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java @@ -11,6 +11,7 @@ import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO; import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity; import com.bsd.pjcloud.canteen.mapper.CanteenSpuMapper; import com.bsd.pjcloud.canteen.service.CanteenSpuService; +import com.bsd.pjcloud.canteen.vo.DailyMenuAdminVO; import com.bsd.pjcloud.canteen.vo.DailyMenuVO; import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity; import com.bsd.pjcloud.canteen.mapper.CanteenDailyMenuMapper; @@ -23,6 +24,7 @@ import com.bsd.pjcloud.mqtt.api.feign.RemoteMqttEventService; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.BeanUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -42,6 +44,7 @@ import java.util.stream.Collectors; */ @Service @AllArgsConstructor +@Slf4j public class CanteenDailyMenuServiceImpl extends ServiceImpl implements CanteenDailyMenuService { private final CanteenSpuService canteenSpuService; @@ -101,29 +104,50 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl spuIds = Optional.ofNullable(canteenDailyMenu.getSpuIds()).orElse(Collections.emptyList()); - if (spuIds.isEmpty()) return; - - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper() - .eq(CanteenDailyMenuEntity::getMerchantId, canteenDailyMenu.getMerchantId()) - .eq(CanteenDailyMenuEntity::getStartTime, canteenDailyMenu.getStartTime()) - .eq(CanteenDailyMenuEntity::getHourType, canteenDailyMenu.getHourType()) - .select(CanteenDailyMenuEntity::getSpuId); // 仅查询必要字段 - - List existSpuIds = baseMapper.selectList(queryWrapper) + if (spuIds.isEmpty()) { + return; + } + List hourTypes = Optional.ofNullable(canteenDailyMenu.getHourTypes()).orElse(Collections.emptyList()) .stream() - .map(CanteenDailyMenuEntity::getSpuId) + .filter(Objects::nonNull) + .distinct() .collect(Collectors.toList()); + if (hourTypes.isEmpty() && canteenDailyMenu.getHourType() != null) { + hourTypes = Collections.singletonList(canteenDailyMenu.getHourType()); + } + if (hourTypes.isEmpty()) { + return; + } - List insertList = spuIds.stream() - .filter(id -> !existSpuIds.contains(id)) - .map(id -> { - CanteenDailyMenuEntity entity = new CanteenDailyMenuEntity(); - BeanUtils.copyProperties(canteenDailyMenu, entity); - entity.setSpuId(id); - return entity; - }) - .collect(Collectors.toList()); - saveBatch(insertList); + List insertList = new ArrayList<>(); + for (Integer hourType : hourTypes) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper() + .eq(CanteenDailyMenuEntity::getMerchantId, canteenDailyMenu.getMerchantId()) + .eq(CanteenDailyMenuEntity::getStartTime, canteenDailyMenu.getStartTime()) + .eq(CanteenDailyMenuEntity::getHourType, hourType) + .select(CanteenDailyMenuEntity::getSpuId); + + List existSpuIds = baseMapper.selectList(queryWrapper) + .stream() + .map(CanteenDailyMenuEntity::getSpuId) + .collect(Collectors.toList()); + + for (Long spuId : spuIds) { + if (existSpuIds.contains(spuId)) { + continue; + } + CanteenDailyMenuEntity entity = new CanteenDailyMenuEntity(); + BeanUtils.copyProperties(canteenDailyMenu, entity); + entity.setSpuId(spuId); + entity.setHourType(hourType); + entity.setHourTypes(null); + entity.setSpuIds(null); + insertList.add(entity); + } + } + if (!insertList.isEmpty()) { + saveBatch(insertList); + } // 发送mq消息 sendMqttMsg(canteenDailyMenu.getMerchantId()); @@ -175,6 +199,57 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl selectAdminList(String time, Long merchantId, Long categoryId) { + List rows = baseMapper.selectAdminList(time, merchantId, categoryId, DataScope.of().only(true)); + if (CollectionUtils.isEmpty(rows)) { + return rows; + } + fillDailyMenuAdminCombo(rows); + for (DailyMenuAdminVO row : rows) { + if (row.getStartTime() != null) { + row.setWeekdayLabel(weekdayLabel(row.getStartTime().getDayOfWeek())); + } + row.setRemainStock(row.getStock()); + } + return rows; + } + + private static String weekdayLabel(DayOfWeek dayOfWeek) { + return switch (dayOfWeek) { + case MONDAY -> "周一"; + case TUESDAY -> "周二"; + case WEDNESDAY -> "周三"; + case THURSDAY -> "周四"; + case FRIDAY -> "周五"; + case SATURDAY -> "周六"; + case SUNDAY -> "周日"; + }; + } + + private void fillDailyMenuAdminCombo(List list) { + if (CollectionUtils.isEmpty(list)) { + return; + } + List comboIds = list.stream() + .filter(v -> Objects.equals(v.getSpuType(), 2) && v.getSpuId() != null) + .map(DailyMenuAdminVO::getSpuId) + .distinct() + .collect(Collectors.toList()); + Map> comboMap = + canteenSpuService.loadComboItemVos(comboIds); + for (DailyMenuAdminVO vo : list) { + int type = vo.getSpuType() == null ? 1 : vo.getSpuType(); + vo.setSpuType(type); + if (type == 2) { + vo.setComboItems(comboMap.getOrDefault(vo.getSpuId(), Collections.emptyList())); + } + else { + vo.setComboItems(Collections.emptyList()); + } + } + } + private void fillDailyMenuCombo(List list) { if (CollectionUtils.isEmpty(list)) { return; 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 008f3853..0f73380e 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 @@ -304,25 +304,25 @@ public class CanteenSpuServiceImpl extends ServiceImpl DyWrapper = Wrappers.lambdaQuery(); - DyWrapper.eq(CanteenDailyMenuEntity::getStartTime, canteenDailySpuDTO.getStartTime()); - DyWrapper.eq(CanteenDailyMenuEntity::getHourType, canteenDailySpuDTO.getHourType()); - DyWrapper.eq(CanteenDailyMenuEntity::getMerchantId, canteenDailySpuDTO.getMerchantId()); - List list = canteenDailyMenuMapper.selectList(DyWrapper); + // 单餐段:排除当日该餐段已选菜品,并按分类餐段过滤;多餐段/未指定则不过滤(保存时按餐段去重) + if (canteenDailySpuDTO.getHourType() != null) { + LambdaQueryWrapper DyWrapper = Wrappers.lambdaQuery(); + DyWrapper.eq(CanteenDailyMenuEntity::getStartTime, canteenDailySpuDTO.getStartTime()); + DyWrapper.eq(CanteenDailyMenuEntity::getHourType, canteenDailySpuDTO.getHourType()); + DyWrapper.eq(CanteenDailyMenuEntity::getMerchantId, canteenDailySpuDTO.getMerchantId()); + List list = canteenDailyMenuMapper.selectList(DyWrapper); - List spuIds = list.stream().map(CanteenDailyMenuEntity::getSpuId).distinct().collect(Collectors.toList()); - wrapper.notIn(!CollectionUtils.isEmpty(list), CanteenSpuEntity::getId, spuIds); + List spuIds = list.stream().map(CanteenDailyMenuEntity::getSpuId).distinct().collect(Collectors.toList()); + wrapper.notIn(!CollectionUtils.isEmpty(list), CanteenSpuEntity::getId, spuIds); - //再次分类餐段过滤 - LambdaQueryWrapper canteenCategoryEntityLambdaQueryWrapper = new LambdaQueryWrapper<>(); - canteenCategoryEntityLambdaQueryWrapper.like(CanteenCategoryEntity::getCategoryType, canteenDailySpuDTO.getHourType()); - List canteenCategoryEntities = canteenCategoryMapper.selectList(canteenCategoryEntityLambdaQueryWrapper); - List caIds = canteenCategoryEntities.stream().map(CanteenCategoryEntity::getId).collect(Collectors.toList()); + LambdaQueryWrapper canteenCategoryEntityLambdaQueryWrapper = new LambdaQueryWrapper<>(); + canteenCategoryEntityLambdaQueryWrapper.like(CanteenCategoryEntity::getCategoryType, canteenDailySpuDTO.getHourType()); + List canteenCategoryEntities = canteenCategoryMapper.selectList(canteenCategoryEntityLambdaQueryWrapper); + List caIds = canteenCategoryEntities.stream().map(CanteenCategoryEntity::getId).collect(Collectors.toList()); - wrapper.in(!CollectionUtils.isEmpty(caIds), CanteenSpuEntity::getCategoryId, caIds); + wrapper.in(!CollectionUtils.isEmpty(caIds), CanteenSpuEntity::getCategoryId, caIds); + } merchantScopeHelper.applyMerchantIdScope(wrapper, canteenDailySpuDTO.getMerchantId(), CanteenSpuEntity::getMerchantId); -// wrapper.orderByAsc(CanteenSpuEntity::getSort); return baseMapper.selectPageByScope(page, wrapper, DataScope.of().only(true)); } @@ -422,6 +422,7 @@ public class CanteenSpuServiceImpl extends ServiceImpl wrapper = new LambdaQueryWrapper<>(); wrapper.like(StrUtil.isNotBlank(canteenSpu.getName()), CanteenSpuEntity::getName, canteenSpu.getName()); wrapper.eq(Objects.nonNull(canteenSpu.getCategoryId()), CanteenSpuEntity::getCategoryId, canteenSpu.getCategoryId()); + applyHourTypeCategoryFilter(wrapper, canteenSpu.getHourType()); merchantScopeHelper.applyMerchantIdScope(wrapper, canteenSpu.getMerchantId(), CanteenSpuEntity::getMerchantId); wrapper.orderByAsc(CanteenSpuEntity::getSort).orderByDesc(CanteenSpuEntity::getId); @@ -438,6 +439,23 @@ public class CanteenSpuServiceImpl extends ServiceImpl wrapper, Integer hourType) { + if (hourType == null) { + return; + } + List caIds = canteenCategoryMapper.selectList(Wrappers.lambdaQuery() + .like(CanteenCategoryEntity::getCategoryType, hourType)) + .stream() + .map(CanteenCategoryEntity::getId) + .collect(Collectors.toList()); + if (CollectionUtils.isEmpty(caIds)) { + wrapper.eq(CanteenSpuEntity::getId, -1L); + return; + } + wrapper.in(CanteenSpuEntity::getCategoryId, caIds); + } + @Override public CanteenSpuEntity getDetail(Long id) { CanteenSpuEntity entity = baseMapper.selectById(id); diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml index e20481f9..256b5b08 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml @@ -173,4 +173,37 @@ AND s.is_show = 0 LIMIT 1 + + From be749076bffd4d3ce9e9ab198971dd1ca75d13ae Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 27 Aug 2026 17:49:40 +0800 Subject: [PATCH 13/31] =?UTF-8?q?feat:=E5=90=8E=E5=8F=B0=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E9=80=80=E6=AC=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/order/HardBatchRefundPreviewDto.java | 25 + .../dto/order/HardBatchRefundSubmitDto.java | 24 + .../hard/api/vo/order/HardAfterSaleLogVo.java | 13 + .../vo/order/HardBatchRefundPreviewVo.java | 156 ++++ .../order/HardAfterSaleController.java | 45 +- .../hard/service/HardAfterSaleCarService.java | 9 + .../hard/service/HardAfterSaleService.java | 9 + .../hard/service/HardBatchRefundService.java | 31 + .../order/HardAfterSaleCarServiceImpl.java | 20 + .../impl/order/HardAfterSaleServiceImpl.java | 56 ++ .../order/HardBatchRefundServiceImpl.java | 733 ++++++++++++++++++ 11 files changed, 1119 insertions(+), 2 deletions(-) create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundPreviewDto.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundSubmitDto.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardBatchRefundPreviewVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBatchRefundService.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchRefundServiceImpl.java diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundPreviewDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundPreviewDto.java new file mode 100644 index 00000000..a0e120b2 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundPreviewDto.java @@ -0,0 +1,25 @@ +package com.bsd.pjcloud.hard.api.dto.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +/** + * 后台批量退款预览请求,只接收列表页已经勾选的订单主键。 + */ +@Data +@Schema(description = "后台批量退款预览请求") +public class HardBatchRefundPreviewDto { + + /** + * 待预览的订单主键,服务端会去重并重新校验数据权限和退款资格,单次最多 1000 笔。 + */ + @NotEmpty(message = "请选择需要退款的订单") + @Size(max = 1000, message = "单次最多选择1000个订单") + @Schema(description = "待退款订单主键,单次最多1000笔") + private List<@NotNull(message = "订单id不能为空") Long> orderIds; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundSubmitDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundSubmitDto.java new file mode 100644 index 00000000..bac72a75 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundSubmitDto.java @@ -0,0 +1,24 @@ +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 HardBatchRefundSubmitDto extends HardBatchRefundPreviewDto { + + /** + * 本批次退款备注,同时写入每笔售后单的申请原因和补充描述,去除首尾空白后不能为空。 + */ + @NotBlank(message = "请输入退款备注") + @Size(max = 200, message = "退款备注不能超过200个字符") + @Schema(description = "本批次统一退款备注,最多200个字符") + private String remark; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleLogVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleLogVo.java index c977c212..d1dc7286 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleLogVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleLogVo.java @@ -1,8 +1,21 @@ package com.bsd.pjcloud.hard.api.vo.order; import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleLogEntity; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import lombok.EqualsAndHashCode; +/** + * App 售后进度记录,在持久化日志之外补充当前节点对应的退款原因。 + */ @Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "App 售后进度记录") public class HardAfterSaleLogVo extends HardAfterSaleLogEntity { + + /** + * 当前进度节点对应的退款或拒绝原因;无可展示原因时为空,不写入售后日志表。 + */ + @Schema(description = "当前进度节点对应的退款原因") + private String reason; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardBatchRefundPreviewVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardBatchRefundPreviewVo.java new file mode 100644 index 00000000..3ff8bac2 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardBatchRefundPreviewVo.java @@ -0,0 +1,156 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +/** + * 后台批量退款预览结果,按订单展示服务端实时核定的剩余可退商品和金额。 + */ +@Data +@Schema(description = "后台批量退款预览结果") +public class HardBatchRefundPreviewVo { + + /** + * 当前预览范围的订单、人员、商品和金额汇总,不包含无权限或不存在的订单。 + */ + @Schema(description = "批量退款汇总") + private Summary summary; + + /** + * 按服务端订单排序返回的预览明细;不可退款订单保留原因但不会进入实际退款任务。 + */ + @Schema(description = "批量退款订单明细") + private List orders; + + /** + * 批量退款预览汇总,数量和金额仅累计当前仍可退款的订单。 + */ + @Data + @Schema(description = "批量退款预览汇总") + public static class Summary { + + /** 当前请求中通过数据权限校验的订单数量。 */ + @Schema(description = "预览订单数") + private Integer selectedOrderCount; + + /** 当前校验通过并会进入退款任务的订单数量。 */ + @Schema(description = "可退款订单数") + private Integer refundableOrderCount; + + /** 因状态、渠道或剩余金额不满足要求而跳过的订单数量。 */ + @Schema(description = "不可退款订单数") + private Integer unavailableOrderCount; + + /** 可退款订单中的去重订餐人数,未知用户不计入。 */ + @Schema(description = "订餐人数") + private Integer userCount; + + /** 可退款订单中按商品主键去重后的菜品种类数。 */ + @Schema(description = "菜品种类数") + private Integer productKindCount; + + /** 可退款数量商品的剩余总份数,不包含称重商品。 */ + @Schema(description = "剩余可退商品总份数") + private Long productCount; + + /** 可退款称重商品的剩余总重量,单位沿用购物车重量协议。 */ + @Schema(description = "剩余可退商品总重量") + private BigDecimal productWeight; + + /** 可退款订单的剩余可退总金额,单位为分。 */ + @Schema(description = "剩余可退总金额,单位分") + private Long refundPrice; + } + + /** + * 单笔订单的批量退款预览信息。 + */ + @Data + @Schema(description = "批量退款订单预览明细") + public static class OrderItem { + + /** Hardware 订单主键,也是异步任务逐笔退款的稳定业务标识。 */ + @Schema(description = "订单主键") + private Long orderId; + + /** 订单业务编号;历史数据可能为空。 */ + @Schema(description = "订单业务编号") + private String orderNo; + + /** 优先取预约用餐日期,其次取支付日期,最后取订单创建日期。 */ + @Schema(description = "退款预览业务日期") + private LocalDate businessDate; + + /** 用餐餐段字典编码;非餐饮或历史订单可能为空。 */ + @Schema(description = "用餐餐段字典编码") + private String hourType; + + /** 实际订餐用户主键;匿名或历史订单可能为空。 */ + @Schema(description = "订餐用户主键") + private Long userId; + + /** 实际订餐人姓名;无法关联用户资料时为空。 */ + @Schema(description = "订餐人姓名") + private String username; + + /** 订餐人下单时关联部门名称;历史数据可能为空。 */ + @Schema(description = "订餐人部门名称") + private String userDeptName; + + /** 订单所属商户名称;无法关联商户资料时为空。 */ + @Schema(description = "商户名称") + private String merchantName; + + /** 当前已完成退款后剩余可退金额,单位为分。 */ + @Schema(description = "剩余可退金额,单位分") + private Integer refundPrice; + + /** 是否通过当前批量退款资格校验。 */ + @Schema(description = "是否可以退款") + private Boolean refundable; + + /** 不可退款原因;可退款时为空。 */ + @Schema(description = "不可退款原因") + private String unavailableReason; + + /** 当前订单扣除待审核和已完成售后占用后的剩余商品。 */ + @Schema(description = "剩余可退商品") + private List products; + } + + /** + * 单个商品的剩余可退数量、重量和商品金额。 + */ + @Data + @Schema(description = "批量退款商品预览明细") + public static class ProductItem { + + /** 原订单购物车快照中的商品主键。 */ + @Schema(description = "商品主键") + private Long productId; + + /** 原订单购物车快照中的商品名称,不回查可能已改名的商品主数据。 */ + @Schema(description = "商品名称") + private String productName; + + /** 商品性质字典编码,用于区分数量商品和称重商品。 */ + @Schema(description = "商品性质字典编码") + private String productNature; + + /** 数量商品的剩余可退份数;称重商品固定为零。 */ + @Schema(description = "剩余可退份数") + private Integer productCount; + + /** 称重商品的剩余可退重量;数量商品固定为零。 */ + @Schema(description = "剩余可退重量") + private BigDecimal productWeight; + + /** 原购物车商品总价扣除有效售后占用后的剩余可退金额,单位为分。 */ + @Schema(description = "剩余可退商品金额,单位分") + private Long refundPrice; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java index 833c0015..9f8b3a75 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java @@ -2,6 +2,7 @@ package com.bsd.pjcloud.hard.controller.order; import cn.hutool.core.util.ObjectUtil; import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; import cn.hutool.core.util.ArrayUtil; import cn.hutool.core.collection.CollUtil; @@ -19,9 +20,12 @@ import com.bsd.pjcloud.hard.api.dto.order.*; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundApplyDto; import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity; import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleLogEntity; +import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleLogVo; import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleVo; +import com.bsd.pjcloud.hard.api.vo.order.HardBatchRefundPreviewVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppRefundApplyVo; import com.bsd.pjcloud.hard.service.HardAfterSaleService; +import com.bsd.pjcloud.hard.service.HardBatchRefundService; import com.bsd.pjcloud.wallet.api.dto.MchManageDto; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; @@ -51,6 +55,9 @@ public class HardAfterSaleController { private final HardAfterSaleService hardAfterSaleService; + /** 批量退款服务,负责权限预览和最多 1000 笔订单的异步逐笔退款。 */ + private final HardBatchRefundService hardBatchRefundService; + private final RemoteMchService remoteMchService; /** @@ -99,12 +106,18 @@ public class HardAfterSaleController { return R.ok(hardAfterSaleService.cancelAfterSale(user.getId(), id)); } + /** + * 查询当前 App 用户可见的售后进度,并在对应业务节点返回退款原因。 + * + * @param id 售后单 ID + * @return 带节点退款原因的售后进度 + */ @ApiCryptoAes @GetMapping(value = "/app/history") @Operation(summary = "app查询售后进度") - public R> appHistory(@RequestParam Long id) { + public R> appHistory(@RequestParam Long id) { hardAfterSaleService.validateAppAfterSaleAccess(id, SecurityUtils.getUser().getId()); - return R.ok(hardAfterSaleService.getHistory(id)); + return R.ok(hardAfterSaleService.getAppHistory(id)); } @ApiCryptoAes @@ -168,6 +181,34 @@ public class HardAfterSaleController { return R.ok(hardAfterSaleService.adminReinforceRefund(dto, user.getId())); } + /** + * 按当前后台用户的数据范围预览勾选订单的日期、餐段、订餐人、剩余商品和退款金额。 + * + * @param dto 列表页跨页勾选的订单主键,单次最多 1000 笔 + * @return 当前时点服务端重新核定的批量退款预览 + */ + @PostMapping(value = "/batch-refund/preview") + @Operation(summary = "后台批量退款预览") + @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')") + public R batchRefundPreview(@Valid @RequestBody HardBatchRefundPreviewDto dto) { + return R.ok(hardBatchRefundService.preview(dto, SecurityUtils.getUser())); + } + + /** + * 提交批量退款异步任务,每笔订单沿用现有整单退款订单锁和独立事务。 + * + * @param dto 订单主键和本批次必填退款备注 + * @return 可通过 Hardware 通用进度接口轮询的初始任务状态 + */ + @PostMapping(value = "/batch-refund/task") + @Operation(summary = "后台批量退款") + @SysLog("后台批量退款") + @Idempotent(expireTime = 5, info = "批量退款任务正在提交,请勿重复操作") + @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')") + public R submitBatchRefund(@Valid @RequestBody HardBatchRefundSubmitDto dto) { + return R.ok(hardBatchRefundService.submit(dto, SecurityUtils.getUser()), "批量退款任务已提交"); + } + @GetMapping(value = "/history") @Operation(summary = "查询售后进度") public R> history(@RequestParam Long id) { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java index 77f024c4..17e8913e 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java @@ -47,6 +47,15 @@ public interface HardAfterSaleCarService extends IService getOccupiedByCarParentId(Long carParentId, List statuses); + /** + * 批量查询多个父购物车在指定售后状态下的商品占用明细,避免退款预览逐订单访问数据库。 + * + * @param carParentIds 父购物车主键集合,空集合不发起数据库查询 + * @param statuses 需要计入占用的售后状态编码集合 + * @return 父购物车主键到商品聚合明细的映射;无占用的购物车不出现在映射中 + */ + Map> getOccupiedByCarParentIds(List carParentIds, List statuses); + /** * 按售后状态为订单购物车展示对象补充原购、已退、申请中和剩余份数。 * 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 156b251b..5913bdd3 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 @@ -10,6 +10,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardDisagreeDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundApplyDto; import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity; import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleLogEntity; +import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleLogVo; import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleVo; import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppRefundApplyVo; @@ -57,6 +58,14 @@ public interface HardAfterSaleService extends IService { */ List getHistory(Long id); + /** + * 获取 App 售后进度,并仅在申请、拒绝或直接退款节点补充对应原因。 + * + * @param id 售后单 ID + * @return App 可见的售后进度记录 + */ + List getAppHistory(Long id); + /** * 退款 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBatchRefundService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBatchRefundService.java new file mode 100644 index 00000000..e863919f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBatchRefundService.java @@ -0,0 +1,31 @@ +package com.bsd.pjcloud.hard.service; + +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; +import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.hard.api.dto.order.HardBatchRefundPreviewDto; +import com.bsd.pjcloud.hard.api.dto.order.HardBatchRefundSubmitDto; +import com.bsd.pjcloud.hard.api.vo.order.HardBatchRefundPreviewVo; + +/** + * 后台订单批量退款服务,负责权限冻结、只读预览和逐笔独立退款任务。 + */ +public interface HardBatchRefundService { + + /** + * 按当前后台用户的数据范围生成批量退款预览。 + * + * @param dto 列表页跨页勾选的订单主键 + * @param user 当前后台登录用户,用于设备类型和商户数据范围校验 + * @return 服务端实时计算的订单、菜品、人员和剩余金额明细 + */ + HardBatchRefundPreviewVo preview(HardBatchRefundPreviewDto dto, PjcloudUser user); + + /** + * 冻结当前仍有权限访问的订单并提交异步逐笔退款任务。 + * + * @param dto 订单主键和本批次统一退款备注 + * @param user 当前后台登录用户,用户主键会记录为每笔退款的审核操作人 + * @return 前端可通过通用进度接口轮询的初始任务状态 + */ + AsyncTaskProgressVO submit(HardBatchRefundSubmitDto dto, PjcloudUser user); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java index 539dc118..b634f089 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java @@ -72,6 +72,26 @@ public class HardAfterSaleCarServiceImpl extends ServiceImpl> getOccupiedByCarParentIds(List carParentIds, + List statuses) { + if (ObjectUtil.isEmpty(carParentIds) || ObjectUtil.isEmpty(statuses)) { + return Map.of(); + } + Map> result = new LinkedHashMap<>(); + baseMapper.getOccupiedByCarParentIds(carParentIds.stream().filter(java.util.Objects::nonNull).distinct().toList(), + statuses) + .forEach(item -> result.computeIfAbsent(item.getParentCarId(), ignored -> new ArrayList<>()).add(item)); + return result; + } + /** * 已完成退款才减少可履约数量,申请中的份数单独返回供页面提示。 * 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 e1c1136b..a5765cbf 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 @@ -38,6 +38,7 @@ import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundApplyDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundItemDto; import com.bsd.pjcloud.hard.api.entity.order.*; import com.bsd.pjcloud.hard.api.utils.HardUtil; +import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleLogVo; import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleVo; import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppRefundApplyVo; @@ -509,6 +510,61 @@ public class HardAfterSaleServiceImpl extends ServiceImpl getAppHistory(Long id) { + List history = getHistory(id); + if (history.isEmpty()) return List.of(); + HardAfterSaleEntity afterSale = baseMapper.selectById(id); + return history.stream().map(log -> { + HardAfterSaleLogVo vo = BeanUtils.toBean(log, HardAfterSaleLogVo.class); + if (afterSale != null) { + vo.setReason(resolveAppHistoryReason(log.getOperateType(), afterSale)); + } + return vo; + }).toList(); + } + + /** + * 按售后操作类型选择当前节点的原因,原因只用于 App 展示且不改变日志持久化内容。 + * + * @param operateType 售后日志操作类型协议编码 + * @param afterSale 当前售后单 + * @return 去除首尾空白的节点原因;该节点无需展示时返回 {@code null} + */ + private String resolveAppHistoryReason(String operateType, HardAfterSaleEntity afterSale) { + if (Objects.equals(operateType, HardAfterSaleOperateTypeEnum.MEMBER_CREATE.getType())) { + return trimToNull(afterSale.getApplyReason()); + } + if (Objects.equals(operateType, HardAfterSaleOperateTypeEnum.ADMIN_DISAGREE_APPLY.getType())) { + return trimToNull(afterSale.getAuditReason()); + } + boolean directRefund = Objects.equals(operateType, HardAfterSaleOperateTypeEnum.ADMIN_REFUND.getType()) + || Objects.equals(operateType, HardAfterSaleOperateTypeEnum.SYSTEM_AUTO_REFUND.getType()) + || Objects.equals(operateType, HardAfterSaleOperateTypeEnum.SYSTEM_EXPIRED_RESERVE_REFUND.getType()); + if (!directRefund) return null; + String applyReason = trimToNull(afterSale.getApplyReason()); + return applyReason == null ? trimToNull(afterSale.getAuditReason()) : applyReason; + } + + /** + * 统一过滤接口原因中的纯空白值,避免 App 渲染空的信息行。 + * + * @param value 数据库存储的原因文本 + * @return 去除首尾空白后的文本;空值或纯空白返回 {@code null} + */ + private String trimToNull(String value) { + if (value == null || value.isBlank()) return null; + return value.trim(); + } + /** * 使用默认退款单号前缀执行售后退款。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchRefundServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchRefundServiceImpl.java new file mode 100644 index 00000000..9254e9d8 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchRefundServiceImpl.java @@ -0,0 +1,733 @@ +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.annotation.ExcelProperty; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.bsd.pjcloud.admin.api.feign.RemoteFileService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum; +import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; +import com.bsd.pjcloud.hard.api.constant.refund.HardSaleStatusEnum; +import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto; +import com.bsd.pjcloud.hard.api.dto.order.HardAdminRefundDto; +import com.bsd.pjcloud.hard.api.dto.order.HardBatchRefundPreviewDto; +import com.bsd.pjcloud.hard.api.dto.order.HardBatchRefundSubmitDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; +import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleCarEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity; +import com.bsd.pjcloud.hard.api.vo.order.HardBatchRefundPreviewVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; +import com.bsd.pjcloud.hard.service.HardAfterSaleCarService; +import com.bsd.pjcloud.hard.service.HardAfterSaleService; +import com.bsd.pjcloud.hard.service.HardBatchRefundService; +import com.bsd.pjcloud.hard.service.HardManagerService; +import com.bsd.pjcloud.hard.service.HardOrderService; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.multipart.MultipartFile; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 后台批量退款服务实现,预览阶段批量聚合数据,执行阶段复用既有单笔整单退款事务。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HardBatchRefundServiceImpl implements HardBatchRefundService { + + /** 批量退款日志标签,用于定位权限校验、逐笔退款和结果文件异常。 */ + private static final String LOG_TAG = "订单批量退款"; + + /** 前后端共同遵守的单批订单上限,防止超大 IN 查询和失控的支付渠道调用。 */ + private static final int MAX_BATCH_ORDER_COUNT = 1000; + + /** 批量退款异步进度使用的稳定 Redis 业务命名空间。 */ + private static final String BATCH_REFUND_REDIS_KEY = "hard:order:batch-refund"; + + /** 通用异步进度弹窗展示的任务名称。 */ + private static final String BATCH_REFUND_TASK_NAME = "订单批量退款"; + + /** 每笔后台整单退款固定使用的仅退款售后方式协议编码。 */ + private static final String REFUND_ONLY_WAY = "10"; + + /** 结果工作簿名称,包含每笔订单成功、失败或跳过原因。 */ + private static final String RESULT_SHEET_NAME = "批量退款结果"; + + /** 文件中心保存批量退款结果的业务目录。 */ + private static final String RESULT_FILE_DIR = "hard-batch-refund"; + + /** 文件中心区分批量退款结果文件的业务类型编码。 */ + private static final String RESULT_FILE_TYPE = "HARD_BATCH_REFUND_RESULT"; + + /** XLSX 结果文件的标准 MIME 类型。 */ + private static final String XLSX_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + + /** 结果文件名时间格式,保证同一操作人连续执行时文件名仍可区分。 */ + private static final DateTimeFormatter RESULT_FILE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + /** 当前支付退款实现支持的第三方原路退款渠道。 */ + private static final Set SUPPORTED_THIRD_PARTY_PAY_TYPES = Set.of( + HardPayTypeEnum.WECHANT_SELF_QR_CODE_PAY, + HardPayTypeEnum.WECHANT_APP_PAY, + HardPayTypeEnum.ALI_SELF_QR_CODE_PAY, + HardPayTypeEnum.ALI_APP_PAY + ); + + /** 订单服务,用于按后台数据范围批量读取订单和原购物车快照。 */ + private final HardOrderService hardOrderService; + + /** 售后服务,复用既有整单退款业务和读取历史售后金额。 */ + private final HardAfterSaleService hardAfterSaleService; + + /** 售后商品服务,用一次查询取得全部订单的待审核和已退款商品占用。 */ + private final HardAfterSaleCarService hardAfterSaleCarService; + + /** 设备权限服务,用于冻结当前后台用户允许查看的设备类型范围。 */ + private final HardManagerService hardManagerService; + + /** 通用异步任务执行器,负责恢复租户、登录和 Feign Token 上下文并保存进度。 */ + private final AsyncTaskExecutorService asyncTaskExecutorService; + + /** 文件中心服务,用于保存最多 1000 笔订单的退款执行结果工作簿。 */ + private final RemoteFileService remoteFileService; + + /** Spring 事务管理器,用于让每笔退款在异步任务外层事务中仍以独立事务提交。 */ + private final PlatformTransactionManager transactionManager; + + /** + * 生成只读退款预览,订单、购物车、售后金额和商品占用均按批量方式读取。 + * + * @param dto 列表页勾选的订单主键 + * @param user 当前后台登录用户 + * @return 当前时点的批量退款预览 + */ + @Override + public HardBatchRefundPreviewVo preview(HardBatchRefundPreviewDto dto, PjcloudUser user) { + return buildPreview(normalizeOrderIds(dto == null ? null : dto.getOrderIds()), user); + } + + /** + * 提交异步退款任务前同步冻结权限与预览状态,避免任务线程接收未经授权的订单主键。 + * + * @param dto 订单主键和统一退款备注 + * @param user 当前后台登录用户 + * @return 通用异步任务初始进度 + */ + @Override + public AsyncTaskProgressVO submit(HardBatchRefundSubmitDto dto, PjcloudUser user) { + if (dto == null || StrUtil.isBlank(dto.getRemark())) { + throw new IllegalArgumentException("请输入退款备注"); + } + String remark = dto.getRemark().trim(); + HardBatchRefundPreviewVo preview = buildPreview(normalizeOrderIds(dto.getOrderIds()), user); + long refundableCount = preview.getOrders().stream().filter(item -> Boolean.TRUE.equals(item.getRefundable())).count(); + if (refundableCount == 0) { + throw new IllegalStateException("所选订单当前均不可退款,请刷新列表后重试"); + } + List orderSnapshot = List.copyOf(preview.getOrders()); + Long operatorId = user == null ? null : user.getId(); + if (operatorId == null) { + throw new IllegalStateException("无法取得当前退款操作人"); + } + return asyncTaskExecutorService.submitWithResult(BATCH_REFUND_REDIS_KEY, BATCH_REFUND_TASK_NAME, + orderSnapshot.size(), reporter -> executeBatch(orderSnapshot, remark, operatorId, reporter)); + } + + /** + * 去重并保留页面勾选顺序,同时执行服务端数量上限兜底。 + * + * @param orderIds 客户端提交的订单主键 + * @return 非空、去重且不超过上限的订单主键 + */ + private List normalizeOrderIds(List orderIds) { + if (orderIds == null || orderIds.isEmpty()) { + throw new IllegalArgumentException("请选择需要退款的订单"); + } + List normalized = orderIds.stream().filter(Objects::nonNull).distinct().toList(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("请选择需要退款的订单"); + } + if (normalized.size() > MAX_BATCH_ORDER_COUNT) { + throw new IllegalArgumentException("单次最多选择" + MAX_BATCH_ORDER_COUNT + "个订单"); + } + return normalized; + } + + /** + * 按订单主键一次装配订单、用户、商户、购物车及售后占用,再计算每笔剩余退款内容。 + * + * @param orderIds 已规范化的订单主键 + * @param user 当前后台登录用户 + * @return 与请求主键顺序一致的退款预览 + */ + private HardBatchRefundPreviewVo buildPreview(List orderIds, PjcloudUser user) { + List scopedOrders = loadScopedOrders(orderIds, user); + Map orderMap = scopedOrders.stream().collect(Collectors.toMap(HardOrderVo::getId, + item -> item, (first, second) -> first, LinkedHashMap::new)); + if (orderMap.size() != orderIds.size()) { + throw new IllegalStateException("部分订单不存在或已超出当前数据权限,请刷新列表后重试"); + } + + Map> afterSalesByOrderId = loadEffectiveAfterSales(orderIds); + List parentCarIds = scopedOrders.stream().map(HardOrderVo::getParentCarId) + .filter(Objects::nonNull).distinct().toList(); + Map> occupiedCarsByParentId = + hardAfterSaleCarService.getOccupiedByCarParentIds(parentCarIds, + List.of(HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus())); + + List items = new ArrayList<>(orderIds.size()); + for (Long orderId : orderIds) { + HardOrderVo order = orderMap.get(orderId); + List afterSales = afterSalesByOrderId.getOrDefault(orderId, List.of()); + List occupiedCars = occupiedCarsByParentId.getOrDefault(order.getParentCarId(), List.of()); + items.add(buildOrderItem(order, afterSales, occupiedCars)); + } + + HardBatchRefundPreviewVo result = new HardBatchRefundPreviewVo(); + result.setOrders(items); + result.setSummary(buildSummary(items)); + return result; + } + + /** + * 复用订单列表的数据范围、设备类型范围和批量关联装配能力读取所选订单。 + * + * @param orderIds 已规范化的订单主键 + * @param user 当前后台登录用户 + * @return 当前用户仍有权限访问的订单及购物车快照 + */ + private List loadScopedOrders(List orderIds, PjcloudUser user) { + if (user == null) { + throw new IllegalStateException("无法取得当前登录用户"); + } + HardOrderDto query = new HardOrderDto(); + query.setOrderIds(orderIds); + query.setIsFindCarList(true); + CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user); + query.setMchTypeCodes(hardTypeInfo == null || hardTypeInfo.getHardTypeList() == null + ? List.of() : new ArrayList<>(hardTypeInfo.getHardTypeList())); + DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL); + dataScope.setScopeDeptName("merchant_dept_id"); + return hardOrderService.scopeList(query, dataScope); + } + + /** + * 一次读取全部待审核和已完成售后单,取消及拒绝记录不占用本次退款金额。 + * + * @param orderIds 预览订单主键 + * @return 订单主键到有效售后记录的映射 + */ + private Map> loadEffectiveAfterSales(List orderIds) { + return hardAfterSaleService.list(new LambdaQueryWrapper() + .in(HardAfterSaleEntity::getOrderId, orderIds) + .in(HardAfterSaleEntity::getStatus, + List.of(HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus())) + .orderByAsc(HardAfterSaleEntity::getId)) + .stream().collect(Collectors.groupingBy(HardAfterSaleEntity::getOrderId, + LinkedHashMap::new, Collectors.toList())); + } + + /** + * 生成单笔订单预览并给出不可退款原因;实际任务仍会在订单锁内重新校验全部条件。 + * + * @param order 当前数据范围内的订单 + * @param afterSales 待审核和已完成售后记录 + * @param occupiedCars 待审核和已完成售后占用的商品汇总 + * @return 单笔订单预览明细 + */ + private HardBatchRefundPreviewVo.OrderItem buildOrderItem(HardOrderVo order, + List afterSales, + List occupiedCars) { + HardBatchRefundPreviewVo.OrderItem item = new HardBatchRefundPreviewVo.OrderItem(); + item.setOrderId(order.getId()); + item.setOrderNo(order.getOrderNo()); + item.setBusinessDate(resolveBusinessDate(order)); + item.setHourType(order.getHourType()); + item.setUserId(order.getUserId()); + item.setUsername(order.getUsername()); + item.setUserDeptName(order.getUserDeptName()); + item.setMerchantName(order.getMerchantName()); + int completedRefundPrice = sumRefundPrice(afterSales, HardSaleStatusEnum.COMPLETE.getStatus()); + item.setRefundPrice(Math.max(0, safePrice(order.getPayPrice()) - completedRefundPrice)); + item.setProducts(buildRemainingProducts(order.getCarList(), occupiedCars)); + String unavailableReason = resolveUnavailableReason(order, afterSales, item.getProducts(), completedRefundPrice); + item.setRefundable(unavailableReason == null); + item.setUnavailableReason(unavailableReason); + return item; + } + + /** + * 业务日期优先体现预约用餐日期,非预约订单再回退到支付或创建日期。 + * + * @param order 当前订单 + * @return 可用于抽屉按日扫描的业务日期;历史时间均为空时返回 {@code null} + */ + private LocalDate resolveBusinessDate(HardOrderVo order) { + if (order.getApptUseDate() != null) return order.getApptUseDate(); + if (order.getPayTime() != null) return order.getPayTime().toLocalDate(); + return order.getCreateTime() == null ? null : order.getCreateTime().toLocalDate(); + } + + /** + * 按商品主键聚合原购物车重复行,并扣除待审核和已完成售后占用的数量、重量及商品金额。 + * + * @param originalCars 原订单购物车快照 + * @param occupiedCars 有效售后商品占用汇总 + * @return 仍有数量或重量可退的商品明细,同时携带对应剩余商品金额 + */ + private List buildRemainingProducts(List originalCars, + List occupiedCars) { + if (ObjectUtil.isEmpty(originalCars)) return List.of(); + Map occupiedByProductId = occupiedCars.stream() + .filter(item -> item != null && item.getProductId() != null) + .collect(Collectors.toMap(HardAfterSaleCarEntity::getProductId, item -> item, + (first, second) -> first, LinkedHashMap::new)); + Map products = new LinkedHashMap<>(); + for (HardCarVo car : originalCars) { + if (car == null) continue; + String key = car.getProductId() == null ? "car:" + car.getId() : "product:" + car.getProductId(); + products.computeIfAbsent(key, ignored -> new ProductAccumulator(car)).add(car); + } + List result = new ArrayList<>(); + for (ProductAccumulator product : products.values()) { + HardAfterSaleCarEntity occupied = occupiedByProductId.get(product.getProductId()); + HardBatchRefundPreviewVo.ProductItem item = product.toRemainingItem(occupied); + if (item != null) result.add(item); + } + return result; + } + + /** + * 按现有单笔退款能力判断渠道、订单类型、历史退款和剩余购物车是否满足批量执行条件。 + * + * @param order 当前订单 + * @param afterSales 当前有效售后记录 + * @param products 扣除有效售后占用后的剩余商品 + * @param completedRefundPrice 已完成退款金额,单位分 + * @return 不可退款原因;返回 {@code null} 表示通过预览校验 + */ + private String resolveUnavailableReason(HardOrderVo order, List afterSales, + List products, + int completedRefundPrice) { + HardOrderTypeEnum orderType = HardOrderTypeEnum.valueOfByType(order.getOrderType()); + if (!HardOrderTypeEnum.isRefund(orderType) || ObjectUtil.equals(orderType, HardOrderTypeEnum.ZY)) { + return "当前订单类型不支持后台退款"; + } + HardPayTypeEnum payType = HardPayTypeEnum.valueOfByType(order.getPayType()); + if (payType == null || !isSupportedRefundChannel(orderType, payType)) { + return "当前支付方式不支持原路退款"; + } + if (safePrice(order.getPayPrice()) <= 0) { + return "订单实付金额异常"; + } + if (afterSales.stream().anyMatch(item -> ObjectUtil.equals(item.getStatus(), HardSaleStatusEnum.APPLY.getStatus()))) { + return "已有退款申请待审核"; + } + if (completedRefundPrice >= safePrice(order.getPayPrice())) { + return "订单已全部退款"; + } + boolean simplePay = HardPayTypeEnum.isSimplePayList.contains(payType) + || HardOrderTypeEnum.isSimplePayList.contains(orderType); + if (!simplePay && products.isEmpty()) { + return "没有剩余可退商品"; + } + return null; + } + + /** + * 退款渠道严格跟随现有售后服务的分支:钱包支付或四类微信、支付宝原路退款。 + * + * @param orderType 订单类型协议枚举 + * @param payType 支付方式协议枚举 + * @return {@code true} 表示现有服务存在对应渠道退款实现 + */ + private boolean isSupportedRefundChannel(HardOrderTypeEnum orderType, HardPayTypeEnum payType) { + return hardOrderService.isPay(orderType, payType) || SUPPORTED_THIRD_PARTY_PAY_TYPES.contains(payType); + } + + /** + * 汇总可退款订单的去重人数、菜品种类、份数、重量和剩余金额。 + * + * @param items 全部预览订单明细 + * @return 只累计可退款订单的汇总 + */ + private HardBatchRefundPreviewVo.Summary buildSummary(List items) { + HardBatchRefundPreviewVo.Summary summary = new HardBatchRefundPreviewVo.Summary(); + List refundableItems = items.stream() + .filter(item -> Boolean.TRUE.equals(item.getRefundable())).toList(); + Set userIds = refundableItems.stream().map(HardBatchRefundPreviewVo.OrderItem::getUserId) + .filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); + Set productKeys = new LinkedHashSet<>(); + long productCount = 0L; + BigDecimal productWeight = BigDecimal.ZERO; + long refundPrice = 0L; + for (HardBatchRefundPreviewVo.OrderItem item : refundableItems) { + refundPrice += safePrice(item.getRefundPrice()); + for (HardBatchRefundPreviewVo.ProductItem product : item.getProducts()) { + productKeys.add(product.getProductId() == null ? "name:" + product.getProductName() + : "id:" + product.getProductId()); + productCount += product.getProductCount() == null ? 0 : Math.max(0, product.getProductCount()); + productWeight = productWeight.add(safeWeight(product.getProductWeight())); + } + } + summary.setSelectedOrderCount(items.size()); + summary.setRefundableOrderCount(refundableItems.size()); + summary.setUnavailableOrderCount(items.size() - refundableItems.size()); + summary.setUserCount(userIds.size()); + summary.setProductKindCount(productKeys.size()); + summary.setProductCount(productCount); + summary.setProductWeight(productWeight); + summary.setRefundPrice(refundPrice); + return summary; + } + + /** + * 在通用异步线程中逐笔退款;每笔使用独立事务,失败会记录结果并继续后续订单。 + * + * @param orders 提交时冻结的预览订单 + * @param remark 去除首尾空白后的统一退款备注 + * @param operatorId 后台退款操作人主键 + * @param reporter 通用异步任务进度报告器 + * @return 包含完整逐笔结果工作簿下载地址的任务结果 + */ + private AsyncTaskResult executeBatch(List orders, String remark, + Long operatorId, AsyncTaskProgressReporter reporter) { + List results = new ArrayList<>(orders.size()); + int successCount = 0; + int failedCount = 0; + for (HardBatchRefundPreviewVo.OrderItem order : orders) { + if (!Boolean.TRUE.equals(order.getRefundable())) { + failedCount++; + results.add(toResultRow(order, "跳过", order.getUnavailableReason())); + } else { + try { + refundOneOrder(order.getOrderId(), remark, operatorId); + successCount++; + results.add(toResultRow(order, "成功", "退款已完成")); + } catch (Exception exception) { + failedCount++; + String message = safeErrorMessage(exception); + log.warn("|{}|单笔退款失败,orderId:{},message:{}", LOG_TAG, order.getOrderId(), message); + results.add(toResultRow(order, "失败", message)); + } + } + reporter.advance("已处理 " + results.size() + " / " + orders.size() + " 笔,成功 " + + successCount + " 笔,失败或跳过 " + failedCount + " 笔"); + } + return createResultFile(results); + } + + /** + * 使用 REQUIRES_NEW 包裹既有整单退款,使异步任务中的某笔失败不会回滚其他已完成退款。 + * + * @param orderId 当前退款订单主键 + * @param remark 本批次统一退款备注 + * @param operatorId 后台退款操作人主键 + */ + private void refundOneOrder(Long orderId, String remark, Long operatorId) { + HardAdminRefundDto refund = new HardAdminRefundDto(); + refund.setOrderId(orderId); + refund.setWay(REFUND_ONLY_WAY); + refund.setApplyReason(remark); + refund.setApplyDescription(remark); + refund.setApplyPicUrls(""); + + TransactionTemplate transaction = new TransactionTemplate(transactionManager); + transaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + Boolean result = transaction.execute(status -> hardAfterSaleService.adminRefund(refund, operatorId)); + if (!Boolean.TRUE.equals(result)) { + throw new IllegalStateException("退款服务未返回成功结果"); + } + } + + /** + * 将单笔执行结果转换为结果工作簿行,金额使用提交任务时的预览剩余金额。 + * + * @param order 提交时冻结的订单预览 + * @param status 执行状态:成功、失败或跳过 + * @param message 面向后台操作人的业务结果说明 + * @return 可直接交给 EasyExcel 的结果行 + */ + private BatchRefundResultExcelRow toResultRow(HardBatchRefundPreviewVo.OrderItem order, String status, + String message) { + BatchRefundResultExcelRow row = new BatchRefundResultExcelRow(); + row.setOrderId(String.valueOf(order.getOrderId())); + row.setOrderNo(order.getOrderNo()); + row.setBusinessDate(order.getBusinessDate() == null ? null : order.getBusinessDate().toString()); + row.setUsername(StrUtil.blankToDefault(order.getUsername(), "未知用户")); + row.setPreviewRefundPrice(BigDecimal.valueOf(safePrice(order.getRefundPrice()), 2) + .setScale(2, RoundingMode.HALF_UP).toPlainString()); + row.setStatus(status); + row.setMessage(message); + return row; + } + + /** + * 生成并上传结果工作簿;文件异常不改变已经逐笔提交的真实退款结果。 + * + * @param results 全部订单执行结果 + * @return 成功上传时携带下载地址,文件异常时返回无附件成功结果 + */ + private AsyncTaskResult createResultFile(List results) { + Path outputPath = null; + String fileName = "批量退款结果_" + LocalDateTime.now().format(RESULT_FILE_TIME_FORMATTER) + ".xlsx"; + try { + outputPath = Files.createTempFile("hard-batch-refund-", ".xlsx"); + EasyExcel.write(outputPath.toFile(), BatchRefundResultExcelRow.class) + .sheet(RESULT_SHEET_NAME).doWrite(results); + return AsyncTaskResult.file(uploadResultFile(outputPath, fileName), fileName); + } catch (Exception exception) { + log.error("|{}|结果文件生成或上传失败,fileName:{}", LOG_TAG, fileName, exception); + return AsyncTaskResult.empty(); + } finally { + deleteTemporaryResultFile(outputPath); + } + } + + /** + * 上传完整结果工作簿并校验文件中心统一业务响应。 + * + * @param file 已关闭并可读取的临时 XLSX 文件 + * @param fileName 用户下载时看到的文件名 + * @return 文件中心下载地址 + * @throws Exception 文件读取或远程上传失败时抛出 + */ + private String uploadResultFile(Path file, String fileName) throws Exception { + MultipartFile resultFile = new MockMultipartFile("file", encodeOriginalFilename(fileName), + XLSX_CONTENT_TYPE, Files.readAllBytes(file)); + R uploadResult = remoteFileService.innerUpload(resultFile, RESULT_FILE_DIR, null, RESULT_FILE_TYPE); + if (uploadResult == null || !uploadResult.isOk() + || !(uploadResult.getData() instanceof Map resultData)) { + throw new IllegalStateException("文件中心未返回有效的批量退款结果"); + } + Object resultUrl = resultData.get("url"); + if (resultUrl == null || StrUtil.isBlank(resultUrl.toString())) { + throw new IllegalStateException("文件中心未返回批量退款结果下载地址"); + } + return resultUrl.toString(); + } + + /** + * 按文件服务历史约定转换中文原始文件名编码。 + * + * @param fileName UTF-8 中文业务文件名 + * @return 文件中心上传接口使用的 ISO-8859-1 原始文件名 + */ + private String encodeOriginalFilename(String fileName) { + return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); + } + + /** + * 删除批量退款临时结果文件,清理失败只记录告警且不覆盖退款结果。 + * + * @param outputPath 待清理的临时路径,允许为空 + */ + private void deleteTemporaryResultFile(Path outputPath) { + if (outputPath == null) return; + try { + Files.deleteIfExists(outputPath); + } catch (Exception exception) { + log.warn("|{}|清理临时结果文件失败,path:{}", LOG_TAG, outputPath, exception); + } + } + + /** + * 提取可展示的业务异常信息并限制长度,避免把技术堆栈或超长下游响应写入结果文件。 + * + * @param exception 单笔退款抛出的异常 + * @return 最多 200 个字符的业务失败说明 + */ + private String safeErrorMessage(Exception exception) { + String message = exception == null ? null : exception.getMessage(); + if (StrUtil.isBlank(message)) return "退款失败,请查看服务日志"; + return message.length() > 200 ? message.substring(0, 200) : message; + } + + /** + * 汇总指定状态售后单金额,历史空金额按零处理并限制在整数分范围内。 + * + * @param afterSales 当前订单有效售后记录 + * @param status 需要汇总的售后状态编码 + * @return 累计金额,单位分 + */ + private int sumRefundPrice(List afterSales, String status) { + long total = afterSales.stream().filter(item -> ObjectUtil.equals(item.getStatus(), status)) + .map(HardAfterSaleEntity::getRefundPrice).filter(Objects::nonNull).mapToLong(Integer::longValue).sum(); + return total > Integer.MAX_VALUE ? Integer.MAX_VALUE : Math.toIntExact(Math.max(0L, total)); + } + + /** + * 将可空金额转换为非负整数分,历史负值按零处理。 + * + * @param price 数据库或预览中的金额 + * @return 可参与退款计算的非负金额 + */ + private int safePrice(Integer price) { + return price == null ? 0 : Math.max(0, price); + } + + /** + * 将可空重量转换为非负值,避免历史负数扩大剩余可退重量。 + * + * @param weight 购物车或售后商品重量 + * @return 非负重量 + */ + private static BigDecimal safeWeight(BigDecimal weight) { + return weight == null ? BigDecimal.ZERO : weight.max(BigDecimal.ZERO); + } + + /** + * 将可空商品金额转换为非负整数分,历史负值按零处理。 + * + * @param price 购物车或售后商品总价,单位分 + * @return 可参与剩余商品金额计算的非负金额 + */ + private static long safeProductPrice(Long price) { + return price == null ? 0L : Math.max(0L, price); + } + + /** + * 同一商品多行购物车快照的原始数量、重量和金额累加器。 + */ + @Data + private static class ProductAccumulator { + + /** 商品主键;无法识别的历史购物车行允许为空。 */ + private final Long productId; + + /** 首行购物车快照中的商品名称。 */ + private final String productName; + + /** 商品性质协议编码,用于选择数量或重量口径。 */ + private final String productNature; + + /** 同一商品全部原购物车行的数量总和。 */ + private int originalCount; + + /** 同一商品全部原购物车行的重量总和。 */ + private BigDecimal originalWeight = BigDecimal.ZERO; + + /** 同一商品全部原购物车行的商品总价,单位为分。 */ + private long originalRefundPrice; + + /** + * 使用首行购物车快照建立商品身份和展示字段。 + * + * @param car 当前商品第一条购物车快照 + */ + private ProductAccumulator(HardCarVo car) { + this.productId = car.getProductId(); + this.productName = car.getProductName(); + this.productNature = car.getProductNature(); + } + + /** + * 累加同一商品购物车行的非负数量、重量和商品总价。 + * + * @param car 同一商品的购物车快照 + */ + private void add(HardCarVo car) { + this.originalCount += car.getProductCount() == null ? 0 : Math.max(0, car.getProductCount()); + this.originalWeight = this.originalWeight.add(safeWeight(car.getProductWeight())); + this.originalRefundPrice = Math.addExact(this.originalRefundPrice, safeProductPrice(car.getTotalPrice())); + } + + /** + * 扣除有效售后占用并转换为抽屉展示商品;无剩余数量或重量时返回空。 + * + * @param occupied 待审核和已完成售后商品占用汇总 + * @return 携带剩余数量、重量和商品金额的可退商品,已无剩余时返回 {@code null} + */ + private HardBatchRefundPreviewVo.ProductItem toRemainingItem(HardAfterSaleCarEntity occupied) { + int occupiedCount = occupied == null || occupied.getProductCount() == null + ? 0 : Math.max(0, occupied.getProductCount()); + BigDecimal occupiedWeight = occupied == null ? BigDecimal.ZERO : safeWeight(occupied.getProductWeight()); + int remainingCount = Math.max(0, originalCount - occupiedCount); + BigDecimal remainingWeight = originalWeight.subtract(occupiedWeight).max(BigDecimal.ZERO); + long occupiedRefundPrice = occupied == null ? 0L : safeProductPrice(occupied.getTotalPrice()); + long remainingRefundPrice = Math.max(0L, originalRefundPrice - occupiedRefundPrice); + boolean countProduct = ObjectUtil.equals(productNature, HardProductNatureEnum.DEFAULT.getType()); + if ((countProduct && remainingCount <= 0) + || (!countProduct && remainingWeight.compareTo(BigDecimal.ZERO) <= 0)) { + return null; + } + HardBatchRefundPreviewVo.ProductItem item = new HardBatchRefundPreviewVo.ProductItem(); + item.setProductId(productId); + item.setProductName(productName); + item.setProductNature(productNature); + item.setProductCount(countProduct ? remainingCount : 0); + item.setProductWeight(countProduct ? BigDecimal.ZERO : remainingWeight); + item.setRefundPrice(remainingRefundPrice); + return item; + } + } + + /** + * 异步批量退款结果工作簿行,保留预览信息和最终逐笔执行状态。 + */ + @Data + public static class BatchRefundResultExcelRow { + + /** Hardware 订单主键,文本格式避免 Excel 科学计数法丢失精度。 */ + @ExcelProperty("订单ID") + private String orderId; + + /** 订单业务编号;历史数据可能为空。 */ + @ExcelProperty("订单编号") + private String orderNo; + + /** 提交任务时冻结的预约、支付或创建业务日期。 */ + @ExcelProperty("业务日期") + private String businessDate; + + /** 提交任务时订单关联的订餐人姓名。 */ + @ExcelProperty("订餐人") + private String username; + + /** 提交任务时服务端计算的剩余可退金额,单位元。 */ + @ExcelProperty("预览可退金额(元)") + private String previewRefundPrice; + + /** 本次异步任务的逐笔状态:成功、失败或跳过。 */ + @ExcelProperty("处理状态") + private String status; + + /** 渠道退款结果或不可退款原因,不包含技术堆栈。 */ + @ExcelProperty("处理说明") + private String message; + } +} From 0d7f1900725784eb15f84b7a536b5fc1c0d4fe57 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 27 Aug 2026 19:17:50 +0800 Subject: [PATCH 14/31] =?UTF-8?q?feat:=E4=BC=98=E5=8C=96=E5=94=AE=E5=90=8E?= =?UTF-8?q?=E8=AE=A2=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hard/api/dto/order/HardAfterSaleDto.java | 16 +++++++ .../hard/api/vo/order/HardAfterSaleVo.java | 6 +++ .../order/HardCanteenApptTotalController.java | 32 ++++++++++--- .../service/HardCanteenApptTotalService.java | 14 +++++- .../HardCanteenApptTotalServiceImpl.java | 45 +++++++++++++++---- .../mapper/order/HardAfterSaleMapper.xml | 23 +++++++++- 6 files changed, 118 insertions(+), 18 deletions(-) diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java index 828d157c..e113b130 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java @@ -10,9 +10,11 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +/** 硬件售后订单后台查询条件,订单侧字段仅用于关联查询,不对应售后表持久化列。 */ @Data public class HardAfterSaleDto extends HardAfterSaleEntity implements BaseUserIdentityQueryInterface { + /** 售后申请用户主键集合;为空时不限制申请人。 */ private List userIds; /** UPMS 用户身份协议编码集合;为空时不限制身份。 */ @@ -27,8 +29,22 @@ public class HardAfterSaleDto extends HardAfterSaleEntity implements BaseUserIde /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */ private String hasFace; + /** 售后申请日期闭区间;为空时不限制申请日期。 */ private LocalDate[] createTimeList; + /** 当前账号数据范围内允许查询的订单商户主键集合;为空时不限制商户。 */ private List mchIds; + /** 关联订单的状态字典编码集合;为空时不限制订单状态。 */ + private List orderStatusCodes; + + /** 关联订单的类型字典编码;为空时不限制订单类型。 */ + private String orderType; + + /** 关联订单的用餐餐段字典编码集合;为空时不限制餐段。 */ + private List hourTypeCodes; + + /** 关联订单预计使用日期闭区间,页面按开始日零点至结束日最后一秒传入。 */ + private LocalDateTime[] apptUseDateTimeList; + } 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 b67f2060..837f2905 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 @@ -11,6 +11,7 @@ import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity; import com.bsd.pjcloud.hard.api.vo.base.BaseOrderInterface; import lombok.Data; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -101,6 +102,11 @@ public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserInte private LocalDateTime orderPayTime; + /** + * 原订单预约用餐日期,来源于硬件订单已有的预约日期字段;非预约订单为空,不对应售后表持久化字段。 + */ + private LocalDate orderApptUseDate; + /** * 当前售后单申请时保存的商品快照,用于审批详情展示本次退款商品及份数;历史未保存明细时为空列表。 */ 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 2ca224f6..eb5be4a0 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 @@ -100,14 +100,37 @@ public class HardCanteenApptTotalController { *

食堂主键不信任客户端输入,而是根据当前商户管理员重新计算;服务层只统计有效预约订单, * 并按已完成退款扣减后的净菜品份数聚合。

* - * @param query 预约用餐日期范围和餐段筛选条件 - * @return 当前账号可管理食堂内的菜品和点餐人员汇总;没有可管理食堂时返回空汇总 + * @param query 预约日期、餐段和核销状态筛选条件 + * @return 当前账号可管理食堂内与 PC 核销状态口径一致的菜品和点餐人员汇总 */ @Operation(summary = "App预约菜品人员统计", description = "按当前账号可管理食堂统计预约菜品和点餐人员净份数") @ApiCryptoAes @PostMapping("/app/user-reserv-statistics") public R appUserReservationStatistics( @Valid @RequestBody HardCanteenUserApptQueryDto query) { + return R.ok(canteenApptTotalService.appUserReservationStatistics(query, resolveAppCanteenMerchantIds())); + } + + /** + * 查询 App 当前可管理食堂范围内按日拆分的预约统计。 + * + * @param query 预约日期、餐段和核销状态筛选条件 + * @return 每日预约统计和查询范围总计;核销状态及日期口径与 PC 人员预约页一致 + */ + @Operation(summary = "App每日预约统计", description = "按当前账号可管理食堂及核销状态统计每日预约") + @ApiCryptoAes + @PostMapping("/app/user-reserv-daily-statistics") + public R appUserReservationDailyStatistics( + @Valid @RequestBody HardCanteenUserApptQueryDto query) { + return R.ok(canteenApptTotalService.appUserReservationDailyStatistics(query, resolveAppCanteenMerchantIds())); + } + + /** + * 解析当前 App 商户管理员可管理的食堂主键,客户端提交的食堂范围不参与授权判定。 + * + * @return 当前账号可管理的食堂主键;没有可管理食堂时返回空集合 + */ + private List resolveAppCanteenMerchantIds() { PjcloudUser user = SecurityUtils.getUser(); if (user == null || user.getId() == null) { throw new IllegalStateException("未获取到当前登录用户"); @@ -119,10 +142,7 @@ public class HardCanteenApptTotalController { throw new IllegalStateException(ObjectUtil.isEmpty(message) ? "查询可管理食堂范围失败" : message); } List merchantIds = merchantResponse.getData(); - if (ObjectUtil.isEmpty(merchantIds)) { - return R.ok(new HardCanteenUserApptStatisticsSummaryVo()); - } - return R.ok(canteenApptTotalService.appUserReservationStatistics(query, merchantIds)); + return ObjectUtil.isEmpty(merchantIds) ? List.of() : merchantIds; } /* ************************************* end app end ************************************* */ 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 adee4d5a..4c3734c3 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 @@ -57,11 +57,21 @@ public interface HardCanteenApptTotalService { /** * 按 App 当前账号可管理的食堂范围统计有效预约菜品及点餐人员。 * - * @param query 预约用餐日期范围和餐段筛选条件 + * @param query 预约日期、餐段和核销状态筛选条件 * @param merchantIds 服务端根据当前账号解析出的可管理食堂主键集合 - * @return 已完成退款扣减后的菜品和点餐人员汇总;食堂范围为空时返回空汇总 + * @return 与 PC 端核销状态口径一致且已完成退款扣减的菜品和点餐人员汇总 */ HardCanteenUserApptStatisticsSummaryVo appUserReservationStatistics( HardCanteenUserApptQueryDto query, List merchantIds); + /** + * 按 App 当前账号可管理的食堂范围统计每日预约及查询范围总计。 + * + * @param query 预约日期、餐段和核销状态筛选条件 + * @param merchantIds 服务端根据当前账号解析出的可管理食堂主键集合 + * @return 与 PC 端核销状态口径一致的逐日预约统计 + */ + HardCanteenUserApptStatisticsVo appUserReservationDailyStatistics( + HardCanteenUserApptQueryDto query, List merchantIds); + } 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 91ee55f9..f26b5763 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 @@ -305,18 +305,34 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ *

与 PC 人员预约统计不同,App 商户视角以可管理食堂中的有效订单为主数据源, * 再按订单用户主键批量补齐姓名和部门,避免商户账号的组织数据权限漏掉实际点餐人员。

* - * @param query 预约用餐日期范围和餐段筛选条件 + * @param query 预约日期、餐段和核销状态筛选条件 * @param merchantIds 服务端解析出的可管理食堂主键集合,禁止使用客户端自行声明的范围 - * @return 完成退款扣减后的菜品及点餐人员汇总 + * @return 与 PC 端核销状态口径一致且完成退款扣减后的菜品及点餐人员汇总 */ @Override public HardCanteenUserApptStatisticsSummaryVo appUserReservationStatistics( HardCanteenUserApptQueryDto query, List merchantIds) { + return appUserReservationDailyStatistics(query, merchantIds).getTotal(); + } + + /** + * 按 App 当前账号可管理食堂统计每日预约及查询范围总计。 + * + *

订单先按商户范围、日期和餐段读取,再复用 PC 人员预约页的核销状态判定筛选用户日期行, + * 保证待核销、部分核销和已核销的协议值及统计日期口径一致。

+ * + * @param query 预约日期、餐段和核销状态筛选条件 + * @param merchantIds 服务端解析出的可管理食堂主键集合,禁止使用客户端自行声明的范围 + * @return 按统计日期升序排列的每日预约统计及范围总计 + */ + @Override + public HardCanteenUserApptStatisticsVo appUserReservationDailyStatistics( + HardCanteenUserApptQueryDto query, List merchantIds) { validateQuery(query); List scopedMerchantIds = merchantIds == null ? List.of() : merchantIds.stream().filter(Objects::nonNull).distinct().toList(); if (scopedMerchantIds.isEmpty()) { - return new HardCanteenUserApptStatisticsSummaryVo(); + return new HardCanteenUserApptStatisticsVo(); } HardOrderDto orderQuery = buildReservationOrderQuery(query, null); @@ -325,20 +341,33 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ List orders = retainEffectiveReservationOrders( hardOrderMapper.scopeList(orderQuery, null), query); if (orders.isEmpty()) { - return new HardCanteenUserApptStatisticsSummaryVo(); + return new HardCanteenUserApptStatisticsVo(); } List userIds = orders.stream().map(HardOrderVo::getUserId) .filter(Objects::nonNull).distinct().toList(); List users = fetchUsersByIds(userIds); - List rows = buildRows(users, query, getQueryDates(query), orders).stream() + List queryDates = getQueryDates(query); + List rows = filterRows(buildRows(users, query, queryDates, orders), query).stream() .filter(row -> row.getOrders() != null && !row.getOrders().isEmpty()).toList(); Map orderMap = orders.stream().filter(order -> order.getId() != null) .collect(Collectors.toMap(HardOrderVo::getId, Function.identity(), (left, right) -> left)); Map> carMap = fetchReservationCars(orders, query); - StatisticsBucket bucket = new StatisticsBucket(); - rows.forEach(row -> bucket.addRow(row, orderMap, carMap)); - return bucket.toSummary(); + + Map dailyBuckets = new LinkedHashMap<>(); + queryDates.forEach(date -> dailyBuckets.put(date, new StatisticsBucket())); + StatisticsBucket totalBucket = new StatisticsBucket(); + rows.forEach(row -> { + StatisticsBucket dailyBucket = dailyBuckets.get(row.getApptUseDate()); + if (dailyBucket == null) return; + 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; } /** diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleMapper.xml index 1d88329d..cd012608 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleMapper.xml @@ -87,17 +87,36 @@ o.total_price as orderTotalPrice, o.extra_price as orderExtraPrice, o.pay_price as orderPayPrice, - o.pay_time as orderPayTime + o.pay_time as orderPayTime, + o.appt_use_date as orderApptUseDate from hard_after_sale a left join hard_order o on o.id = a.order_id - + and o.merchant_id in #{v} + + and o.order_status in + + #{v} + + + + and o.order_type = #{dto.orderType} + + + and o.hour_type in + + #{v} + + + + and o.appt_use_date between #{dto.apptUseDateTimeList[0]} and #{dto.apptUseDateTimeList[1]} + order by a.create_time desc,a.id desc From cb74d0563fdc5694da7b0609c1bcdd437be7e2da Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 27 Aug 2026 21:51:19 +0800 Subject: [PATCH 15/31] 11 --- .../HardCanteenUserApptDishStatisticsVo.java | 4 + .../order/HardOrderTotalController.java | 71 ++- .../HardCanteenApptTotalServiceImpl.java | 8 + .../impl/order/HardOrderTotalServiceImpl.java | 494 +++++++++--------- .../mapper/order/HardOrderMapper.xml | 5 +- .../wallet/api/feign/RemoteMchService.java | 130 +++++ .../controller/mch/MchManageController.java | 16 + .../wallet/service/MchManageService.java | 9 + .../impl/mch/MchManageServiceImpl.java | 27 + 9 files changed, 527 insertions(+), 237 deletions(-) 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 index cff3371a..581e8637 100644 --- 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 @@ -21,6 +21,10 @@ public class HardCanteenUserApptDishStatisticsVo { @Schema(description = "菜品名称") private String productName; + /** 当前同餐段、同名菜品统计项实际涉及的商品 ID;同名商品可能因重复建档包含多个 ID,历史空值不写入集合。 */ + @Schema(description = "商品ID集合") + private List productIds = new ArrayList<>(); + /** 下单时固化的菜品图片地址;历史订单未保存图片时为空。 */ @Schema(description = "菜品图片地址") private String productPic; diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java index db2cebc9..6451c2a5 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java @@ -47,8 +47,10 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; @RestController @RequiredArgsConstructor @@ -151,12 +153,70 @@ public class HardOrderTotalController { return R.ok(hardOrderTotalService.listPayType(dto)); } + /** + * 查询当前用户所属食堂商户范围内的部门交易统计。 + * + * @param dto 时间、钱包类型及部门树筛选条件;部门条件会与当前用户可管理的食堂部门取交集 + * @return 当前用户有权查看的部门交易统计,无可见食堂或部门时返回空列表 + */ @PostMapping("/page-list-dept") @Operation(summary = "部门交易记录") public R> pageListDept(@RequestBody HardDeptTotalDto dto) { + if (!applyCanteenDepartmentScope(dto)) { + return R.ok(List.of()); + } return R.ok(hardOrderTotalService.pageListDept(dto)); } + /** + * 将部门统计条件限制到当前用户可管理的食堂商户所属部门。 + * + *

钱包服务根据当前用户主键和食堂商户类型一次性返回其所属食堂覆盖的用户部门。页面传入的 + * 包含或排除条件只在该权限部门集合内生效,最终统一改写为明确的包含集合,避免空集合或排除 + * 条件导致底层统计退化为全量查询。

+ * + * @param dto 待收敛的部门统计条件,方法会写入最终允许查询的部门 ID 集合 + * @return 是否仍存在可查询部门;false 表示调用方必须直接返回空数据 + */ + private boolean applyCanteenDepartmentScope(HardDeptTotalDto dto) { + PjcloudUser user = SecurityUtils.getUser(); + if (user == null || user.getId() == null) { + return false; + } + + R> departmentResponse = mchService.getSelfMchUserDeptIdListByUserId( + user.getId(), List.of(MerchantTypeEnum.CANTEEN)); + if (departmentResponse == null || !departmentResponse.isOk() || ObjectUtil.isEmpty(departmentResponse.getData())) { + return false; + } + List allowedDeptIds = departmentResponse.getData().stream() + .filter(id -> id != null && id > 0) + .distinct() + .toList(); + if (ObjectUtil.isEmpty(allowedDeptIds)) { + return false; + } + + List requestedDeptIds = ObjectUtil.isNotEmpty(dto.getDeptIds()) + ? dto.getDeptIds() + : ObjectUtil.isNotEmpty(dto.getDeptId()) ? List.of(dto.getDeptId()) : List.of(); + List scopedDeptIds = allowedDeptIds; + if (ObjectUtil.isNotEmpty(requestedDeptIds)) { + Set requestedDeptIdSet = new HashSet<>(requestedDeptIds); + scopedDeptIds = Boolean.FALSE.equals(dto.getIsInclude()) + ? allowedDeptIds.stream().filter(id -> !requestedDeptIdSet.contains(id)).toList() + : allowedDeptIds.stream().filter(requestedDeptIdSet::contains).toList(); + } + if (ObjectUtil.isEmpty(scopedDeptIds)) { + return false; + } + + dto.setDeptId(null); + dto.setDeptIds(scopedDeptIds); + dto.setIsInclude(true); + return true; + } + @Operation(summary = "设备交易记录导出", description = "设备交易记录导出") @PostMapping("/export-sb") @SysLog("设备交易记录导出") @@ -465,12 +525,21 @@ public class HardOrderTotalController { } } + /** + * 导出当前用户所属食堂商户范围内的部门交易统计。 + * + * @param response Excel 文件响应 + * @param dto 时间、钱包类型及部门树筛选条件;部门条件会与当前用户可管理的食堂部门取交集 + * @throws IOException 写入 Excel 响应失败时抛出 + */ @Operation(summary = "部门交易记录导出", description = "部门交易记录导出") @PostMapping("/export-dept") @SysLog("部门交易记录导出") @PreAuthorize("@pms.hasPermission('hard_hardTotal_export_dept')") public void exportDept(HttpServletResponse response, @RequestBody HardDeptTotalDto dto) throws IOException { - List list = hardOrderTotalService.pageListDept(dto); + List list = applyCanteenDepartmentScope(dto) + ? hardOrderTotalService.pageListDept(dto) + : List.of(); try (ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).withTemplate(ResourceUtil.getStream("file/部门交易金额汇总.xlsx")).build()) { WriteSheet writeSheet = EasyExcel.writerSheet().build(); FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build(); 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 f26b5763..9bdd4116 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 @@ -43,6 +43,7 @@ import java.time.LocalTime; import java.util.ArrayList; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -1122,6 +1123,9 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ String dishKey = hourType + "\u0000" + productName; DishStatisticsAccumulator dish = dishes.computeIfAbsent(dishKey, key -> new DishStatisticsAccumulator(hourType, productName, car.getProductPic())); + if (car.getProductId() != null) { + dish.productIds.add(car.getProductId()); + } // 同餐段同名菜品合并时优先保留首个非空快照图片,兼容早期订单未保存图片的情况。 if (StrUtil.isBlank(dish.productPic) && StrUtil.isNotBlank(car.getProductPic())) { dish.productPic = car.getProductPic(); @@ -1243,6 +1247,9 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ /** 下单时固化的菜品名称。 */ private final String productName; + /** 同餐段、同名菜品实际涉及的商品 ID,保留首次出现顺序并去重,便于排查同名商品重复建档。 */ + private final Set productIds = new LinkedHashSet<>(); + /** 下单时固化的菜品图片地址;同名菜品聚合时保留首个非空值。 */ private String productPic; @@ -1276,6 +1283,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ HardCanteenUserApptDishStatisticsVo vo = new HardCanteenUserApptDishStatisticsVo(); vo.setHourType(hourType); vo.setProductName(productName); + vo.setProductIds(new ArrayList<>(productIds)); vo.setProductPic(productPic); vo.setProductCount(productCount); vo.setOrderCount((long) orderIds.size()); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalServiceImpl.java index 735eaa76..d4143779 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalServiceImpl.java @@ -9,6 +9,7 @@ import com.bsd.pjcloud.admin.api.entity.SysDept; import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; import com.bsd.pjcloud.common.core.util.BeanUtils; import com.bsd.pjcloud.common.core.util.CollectionUtils; +import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum; import com.bsd.pjcloud.common.security.service.PjcloudUser; @@ -47,6 +48,8 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -194,7 +197,10 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { } /** - * 商户交易记录 + * 汇总商户交易记录,并补全商户及商户部门展示信息。 + * + * @param dto 商户、部门、用餐时段及交易时间等统计条件 + * @return 按商户汇总的流水、退款和净消费数据;历史部门不存在时部门名为空 */ @Override public List listCs(HardOrderDto dto) { @@ -202,85 +208,88 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { if (ObjectUtil.isEmpty(dto.getMchTypeCodes())) { CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user); dto.setMchTypeCodes(hardTypeInfo.getHardTypeList()); - } else { - dto.setMchTypeCodes(dto.getMchTypeCodes()); } - // 查询总流水 + + // 流水与退款的业务发生时间不同,必须分别查询后再按商户合并,避免直接关联造成一笔订单被多笔售后重复放大。 HardOrderDto totalDto = BeanUtil.toBean(dto, HardOrderDto.class); totalDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice()); DataScope totalDataScope = DataScope.of(); totalDataScope.setScopeDeptName("merchant_dept_id"); List totalList = hardOrderMapper.listCs(totalDto, totalDataScope); - // 扣款总流水 + HardOrderDto refundDto = BeanUtil.toBean(dto, HardOrderDto.class); refundDto.setPayTimeList(null); refundDto.setRefundTimeList(dto.getPayTimeList()); - refundDto.setOrderStatusCodes(HardOrderStatusEnum.getPayErrorCodes()); - refundDto.setRefundStatusCodes(List.of(HardSaleStatusEnum.COMPLETE.getStatus())); + // refundStatusCodes 属于订单退款状态,售后完成状态必须使用 saleStatusCodes,二者混用会把有效退款全部过滤掉。 + refundDto.setRefundStatusCodes(null); + refundDto.setSaleStatusCodes(List.of(HardSaleStatusEnum.COMPLETE.getStatus())); + // 部分退款后订单仍是已支付状态,退款统计不能只查询整单退款状态的订单。 + refundDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice()); DataScope refundDataScope = DataScope.of(); refundDataScope.setScopeDeptName("merchant_dept_id"); - List refundList = hardOrderMapper.listRefundCs(refundDto, refundDataScope); - // 有退款没有流水的情况 - if (ObjectUtil.isAllNotEmpty(totalList, refundList)) { - List totalIds = totalList.stream().map(HardOrderCsVo::getMerchantId).toList(); - for (HardOrderCsVo refund : refundList) { - if (!totalIds.contains(refund.getMerchantId())) { - HardOrderCsVo entity = BeanUtils.toBean(refund, HardOrderCsVo.class); - entity.setTotalPayPrice(BigDecimal.ZERO); - entity.setTotalPayCount(BigDecimal.ZERO); - totalList.add(entity); - } - } - } - for (HardOrderCsVo v : totalList) { - // 退款 - if (ObjectUtil.isNotEmpty(refundList)) { - refundList.stream().filter(i -> ObjectUtil.equals(i.getMerchantId(), v.getMerchantId())).findFirst().ifPresent(i -> { - v.setTotalRefundPrice(i.getTotalPayPrice()); - v.setTotalRefundCount(i.getTotalPayCount()); - }); - } - // 消费净金额 - if (ObjectUtil.isNotEmpty(v.getTotalPayPrice())) { - BigDecimal refundPrice = BigDecimal.ZERO; - if (ObjectUtil.isNotEmpty(v.getTotalRefundPrice())) { - refundPrice = v.getTotalRefundPrice(); - } - v.setTotalProfitPrice(v.getTotalPayPrice().subtract(refundPrice)); - } - // 消费净次数 - if (ObjectUtil.isNotEmpty(v.getTotalPayCount())) { - BigDecimal refundCount = BigDecimal.ZERO; - if (ObjectUtil.isNotEmpty(v.getTotalRefundCount())) { - refundCount = v.getTotalRefundCount(); - } - v.setTotalProfitCount(v.getTotalPayCount().subtract(refundCount)); - } - } - // 聚合一下数据 + + // 使用有序 Map 一次完成合并,同时保留原流水排序,并覆盖筛选期内只有退款、没有新支付的商户。 + Map statisticsMap = new LinkedHashMap<>(); if (ObjectUtil.isNotEmpty(totalList)) { + for (HardOrderCsVo total : totalList) { + statisticsMap.put(total.getMerchantId(), total); + } + } + if (ObjectUtil.isNotEmpty(refundList)) { + for (HardOrderCsVo refund : refundList) { + HardOrderCsVo statistics = statisticsMap.computeIfAbsent(refund.getMerchantId(), merchantId -> { + HardOrderCsVo refundOnly = BeanUtils.toBean(refund, HardOrderCsVo.class); + refundOnly.setTotalPayPrice(BigDecimal.ZERO); + refundOnly.setTotalPayCount(BigDecimal.ZERO); + return refundOnly; + }); + statistics.setTotalRefundPrice(ObjectUtil.defaultIfNull(refund.getTotalPayPrice(), BigDecimal.ZERO)); + statistics.setTotalRefundCount(ObjectUtil.defaultIfNull(refund.getTotalPayCount(), BigDecimal.ZERO)); + } + } + + List statisticsList = new ArrayList<>(statisticsMap.values()); + for (HardOrderCsVo statistics : statisticsList) { + BigDecimal totalPayPrice = ObjectUtil.defaultIfNull(statistics.getTotalPayPrice(), BigDecimal.ZERO); + BigDecimal totalPayCount = ObjectUtil.defaultIfNull(statistics.getTotalPayCount(), BigDecimal.ZERO); + BigDecimal totalRefundPrice = ObjectUtil.defaultIfNull(statistics.getTotalRefundPrice(), BigDecimal.ZERO); + BigDecimal totalRefundCount = ObjectUtil.defaultIfNull(statistics.getTotalRefundCount(), BigDecimal.ZERO); + statistics.setTotalPayPrice(totalPayPrice); + statistics.setTotalPayCount(totalPayCount); + statistics.setTotalRefundPrice(totalRefundPrice); + statistics.setTotalRefundCount(totalRefundCount); + statistics.setTotalProfitPrice(totalPayPrice.subtract(totalRefundPrice)); + statistics.setTotalProfitCount(totalPayCount.subtract(totalRefundCount)); + } + + if (ObjectUtil.isNotEmpty(statisticsList)) { // 商户 - List mchIds = totalList.stream().map(HardOrderCsVo::getMerchantId).distinct().toList(); + List mchIds = statisticsList.stream().map(HardOrderCsVo::getMerchantId).filter(ObjectUtil::isNotEmpty).distinct().toList(); if (ObjectUtil.isNotEmpty(mchIds)) { - List mchList = remoteMchService.getByIds(mchIds).getData(); - if (ObjectUtil.isNotEmpty(mchList)) { - WalletDataUtil.convertMchList(totalList, CollectionUtils.convertMap(mchList, MchManageVo::getId)); + R> mchResponse = remoteMchService.getByIds(mchIds); + if (ObjectUtil.isNotEmpty(mchResponse) && mchResponse.isOk() && ObjectUtil.isNotEmpty(mchResponse.getData())) { + WalletDataUtil.convertMchList(statisticsList, CollectionUtils.convertMap(mchResponse.getData(), MchManageVo::getId)); } } // 商户部门 - List mchDeptIds = totalList.stream().map(HardOrderCsVo::getMerchantDeptId).distinct().toList(); + List mchDeptIds = statisticsList.stream().map(HardOrderCsVo::getMerchantDeptId).filter(ObjectUtil::isNotEmpty).distinct().toList(); if (ObjectUtil.isNotEmpty(mchDeptIds)) { - Map deptMap = remoteDeptService.getDeptByDeptIds(mchDeptIds).getData(); - if (ObjectUtil.isNotEmpty(deptMap)) { - for (HardOrderCsVo v : totalList) { + R> deptResponse = remoteDeptService.getDeptByDeptIds(mchDeptIds); + if (ObjectUtil.isNotEmpty(deptResponse) && deptResponse.isOk() && ObjectUtil.isNotEmpty(deptResponse.getData())) { + Map deptMap = deptResponse.getData(); + for (HardOrderCsVo v : statisticsList) { if (ObjectUtil.isEmpty(v.getMerchantDeptId())) continue; - v.setMerchantDeptName(deptMap.get(v.getMerchantDeptId()).getName()); + SysDept dept = deptMap.get(v.getMerchantDeptId()); + // 历史订单关联的商户部门可能已删除或已不在当前租户可见范围,不能因名称缺失阻断整页统计。 + if (ObjectUtil.isNotEmpty(dept)) { + v.setMerchantDeptName(dept.getName()); + } } } } } - return totalList; + return statisticsList; } @Override @@ -393,11 +402,88 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { } /** - * 部门交易记录 + * 按用户部门汇总钱包操作、硬件消费、已完成退款和净消费数据。 + * + * @param dto 当前用户权限范围内的部门、时间和钱包类型筛选条件 + * @return 部门交易统计;筛选范围内没有部门时返回空列表 + * @throws IllegalStateException 部门或钱包远程统计调用失败时抛出,避免返回不完整报表 */ @Override public List pageListDept(HardDeptTotalDto dto) { - // 查询部门 + List deptList = queryDepartmentList(dto); + if (ObjectUtil.isEmpty(deptList)) { + return List.of(); + } + + List walletBatchList = queryWalletStatistics( + buildWalletStatisticsDto(dto, WalletBizEnum.BATCH.getCode(), WalletTransactionSumAndCountDto.TYPE_INCOME)); + List walletReduceList = queryWalletStatistics( + buildWalletStatisticsDto(dto, WalletBizEnum.BATCH.getCode(), WalletTransactionSumAndCountDto.TYPE_EXPENSE)); + List walletReturnCanteenList = queryWalletStatistics( + buildWalletStatisticsDto(dto, WalletBizEnum.RETURN_CANTEEN.getCode(), null)); + List walletOnceBatchList = queryWalletStatistics( + buildWalletStatisticsDto(dto, WalletBizEnum.UPDATE.getCode(), WalletTransactionSumAndCountDto.TYPE_INCOME)); + List walletOnceReduceList = queryWalletStatistics( + buildWalletStatisticsDto(dto, WalletBizEnum.UPDATE.getCode(), WalletTransactionSumAndCountDto.TYPE_EXPENSE)); + + List payList = hardOrderMapper.sumAndCount(buildOrderStatisticsDto(dto, false)); + List refundList = hardOrderMapper.refundSumAndCount(buildOrderStatisticsDto(dto, true)); + + Map walletBatchMap = indexWalletStatistics(walletBatchList); + Map walletReduceMap = indexWalletStatistics(walletReduceList); + Map walletReturnCanteenMap = indexWalletStatistics(walletReturnCanteenList); + Map walletOnceBatchMap = indexWalletStatistics(walletOnceBatchList); + Map walletOnceReduceMap = indexWalletStatistics(walletOnceReduceList); + Map payMap = indexOrderStatistics(payList); + Map refundMap = indexOrderStatistics(refundList); + + List resultList = new ArrayList<>(deptList.size()); + for (SysDept dept : deptList) { + HardDeptTotalVo statistics = new HardDeptTotalVo(); + statistics.setDeptId(dept.getDeptId()); + statistics.setDeptName(dept.getName()); + + WalletTransactionSumAndCountVo walletBatch = walletBatchMap.get(dept.getDeptId()); + if (walletBatch != null) { + statistics.setWalletPrice(BigDecimal.valueOf(ObjectUtil.defaultIfNull(walletBatch.getSum(), 0L))); + statistics.setWalletCount(ObjectUtil.defaultIfNull(walletBatch.getCount(), 0L)); + } + WalletTransactionSumAndCountVo walletReduce = walletReduceMap.get(dept.getDeptId()); + if (walletReduce != null) { + statistics.setWalletReducePrice(Math.abs(ObjectUtil.defaultIfNull(walletReduce.getSum(), 0L))); + statistics.setWalletReduceCount(ObjectUtil.defaultIfNull(walletReduce.getCount(), 0L)); + } + WalletTransactionSumAndCountVo walletReturnCanteen = walletReturnCanteenMap.get(dept.getDeptId()); + if (walletReturnCanteen != null) { + statistics.setWalletReturnCanteenPrice(BigDecimal.valueOf(Math.abs( + ObjectUtil.defaultIfNull(walletReturnCanteen.getSum(), 0L)))); + statistics.setWalletReturnCanteenCount(ObjectUtil.defaultIfNull(walletReturnCanteen.getCount(), 0L)); + } + WalletTransactionSumAndCountVo walletOnceBatch = walletOnceBatchMap.get(dept.getDeptId()); + if (walletOnceBatch != null) { + statistics.setWalletOncePrice(BigDecimal.valueOf(ObjectUtil.defaultIfNull(walletOnceBatch.getSum(), 0L))); + statistics.setWalletOnceCount(ObjectUtil.defaultIfNull(walletOnceBatch.getCount(), 0L)); + } + WalletTransactionSumAndCountVo walletOnceReduce = walletOnceReduceMap.get(dept.getDeptId()); + if (walletOnceReduce != null) { + statistics.setWalletOnceReducePrice(BigDecimal.valueOf(Math.abs( + ObjectUtil.defaultIfNull(walletOnceReduce.getSum(), 0L)))); + statistics.setWalletOnceReduceCount(ObjectUtil.defaultIfNull(walletOnceReduce.getCount(), 0L)); + } + applyOrderStatistics(statistics, payMap.get(dept.getDeptId()), refundMap.get(dept.getDeptId())); + resultList.add(statistics); + } + return resultList; + } + + /** + * 查询统计结果需要展示的部门,并校验 UPMS 远程调用结果。 + * + * @param dto 部门包含或排除筛选条件 + * @return 匹配的部门列表,远程响应数据为空时返回空列表 + * @throws IllegalStateException UPMS 部门远程调用失败时抛出 + */ + private List queryDepartmentList(HardDeptTotalDto dto) { DeptAllListDto deptDto = new DeptAllListDto(); deptDto.setIsInclude(dto.getIsInclude()); if (ObjectUtil.isNotEmpty(dto.getDeptId())) { @@ -407,186 +493,126 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { deptDto.setDeptId(null); deptDto.setDeptIds(dto.getDeptIds()); } - List deptList = remoteDeptService.getInnerAllDept(deptDto).getData(); - if (ObjectUtil.isEmpty(deptList)) return null; - // List deptIds = deptList.stream().map(SysDept::getDeptId).filter(ObjectUtil::isNotEmpty).distinct().toList(); - List resultList = deptList.stream().map(v -> { - HardDeptTotalVo item = new HardDeptTotalVo(); - item.setDeptId(v.getDeptId()); - item.setDeptName(v.getName()); - return item; - }).toList(); - // 查询钱包批量充值流水 - CompletableFuture> walletBatchFuture = CompletableFuture.supplyAsync(() -> { - WalletTransactionSumAndCountDto walletDto = new WalletTransactionSumAndCountDto(); - walletDto.setDeptIds(dto.getDeptIds()); - walletDto.setIsInclude(dto.getIsInclude()); - walletDto.setBizTypes(List.of(WalletBizEnum.BATCH.getCode())); - walletDto.setType(WalletTransactionSumAndCountDto.TYPE_INCOME); - if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) { - walletDto.setCreateTimeList(dto.getCreateTimeList()); - } - if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) { - walletDto.setWalletTypeId(dto.getWalletTypeId()); - } - return remoteWalletTransactionService.sumAndCount(walletDto).getData(); - }); - // 查询钱包批量扣减流水 - CompletableFuture> walletReduceFuture = CompletableFuture.supplyAsync(() -> { - WalletTransactionSumAndCountDto walletDto = new WalletTransactionSumAndCountDto(); - walletDto.setDeptIds(dto.getDeptIds()); - walletDto.setIsInclude(dto.getIsInclude()); - walletDto.setBizTypes(List.of(WalletBizEnum.BATCH.getCode())); - walletDto.setType(WalletTransactionSumAndCountDto.TYPE_EXPENSE); - if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) { - walletDto.setCreateTimeList(dto.getCreateTimeList()); - } - if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) { - walletDto.setWalletTypeId(dto.getWalletTypeId()); - } - return remoteWalletTransactionService.sumAndCount(walletDto).getData(); - }); - // 查询钱包退还各自食堂 - CompletableFuture> walletRetrunCanteenFuture = CompletableFuture.supplyAsync(() -> { - WalletTransactionSumAndCountDto walletDto = new WalletTransactionSumAndCountDto(); - walletDto.setDeptIds(dto.getDeptIds()); - walletDto.setIsInclude(dto.getIsInclude()); - walletDto.setBizTypes(List.of(WalletBizEnum.RETURN_CANTEEN.getCode())); - // walletDto.setType(WalletTransactionSumAndCountDto.TYPE_EXPENSE); - if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) { - walletDto.setCreateTimeList(dto.getCreateTimeList()); - } - if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) { - walletDto.setWalletTypeId(dto.getWalletTypeId()); - } - return remoteWalletTransactionService.sumAndCount(walletDto).getData(); - }); - // 查询钱包单次充值流水 - CompletableFuture> walletOnceBatchFuture = CompletableFuture.supplyAsync(() -> { - WalletTransactionSumAndCountDto walletDto = new WalletTransactionSumAndCountDto(); - walletDto.setDeptIds(dto.getDeptIds()); - walletDto.setIsInclude(dto.getIsInclude()); - walletDto.setBizTypes(List.of(WalletBizEnum.UPDATE.getCode())); - walletDto.setType(WalletTransactionSumAndCountDto.TYPE_INCOME); - if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) { - walletDto.setCreateTimeList(dto.getCreateTimeList()); - } - if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) { - walletDto.setWalletTypeId(dto.getWalletTypeId()); - } - return remoteWalletTransactionService.sumAndCount(walletDto).getData(); - }); - // 查询钱包单次扣减流水 - CompletableFuture> walletOnceReduceFuture = CompletableFuture.supplyAsync(() -> { - WalletTransactionSumAndCountDto walletDto = new WalletTransactionSumAndCountDto(); - walletDto.setDeptIds(dto.getDeptIds()); - walletDto.setIsInclude(dto.getIsInclude()); - walletDto.setBizTypes(List.of(WalletBizEnum.UPDATE.getCode())); - walletDto.setType(WalletTransactionSumAndCountDto.TYPE_EXPENSE); - if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) { - walletDto.setCreateTimeList(dto.getCreateTimeList()); - } - if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) { - walletDto.setWalletTypeId(dto.getWalletTypeId()); - } - return remoteWalletTransactionService.sumAndCount(walletDto).getData(); - }); - // 查询订单支付流水 - CompletableFuture> payFuture = CompletableFuture.supplyAsync(() -> { - HardOrderSumAndCountDto payDto = new HardOrderSumAndCountDto(); - payDto.setUserDeptIds(dto.getDeptIds()); - payDto.setIsInclude(dto.getIsInclude()); - payDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice()); - if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) { - payDto.setCreateTimeList(dto.getCreateTimeList()); - } - if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) { - payDto.setWalletTypeId(dto.getWalletTypeId()); - } - return hardOrderMapper.sumAndCount(payDto); - }); - // 查询订单退款流水 - CompletableFuture> refundFuture = CompletableFuture.supplyAsync(() -> { - HardOrderSumAndCountDto refundDto = new HardOrderSumAndCountDto(); - refundDto.setUserDeptIds(dto.getDeptIds()); - refundDto.setIsInclude(dto.getIsInclude()); - refundDto.setOrderStatusCodes(HardOrderStatusEnum.getPayErrorCodes()); - refundDto.setRefundStatusCodes(List.of(HardSaleStatusEnum.COMPLETE.getStatus())); - if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) { - refundDto.setRefundTimeList(dto.getCreateTimeList()); - } - if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) { - refundDto.setWalletTypeId(dto.getWalletTypeId()); - } - return hardOrderMapper.refundSumAndCount(refundDto); - }); - CompletableFuture allFutures = CompletableFuture.allOf(walletBatchFuture, walletReduceFuture, payFuture, refundFuture, walletOnceBatchFuture, walletOnceReduceFuture); - allFutures.join(); - List walletBatchList = walletBatchFuture.join(); - List walletReduceList = walletReduceFuture.join(); - List walletRetrunCanteenList = walletRetrunCanteenFuture.join(); - List walletOnceBatchList = walletOnceBatchFuture.join(); - List walletOnceReduceList = walletOnceReduceFuture.join(); - List payList = payFuture.join(); - List refundList = refundFuture.join(); - // 聚合一下数据 - for (HardDeptTotalVo v : resultList) { - if (ObjectUtil.isNotEmpty(walletBatchList)) { - walletBatchList.stream().filter(i -> ObjectUtil.equals(i.getDeptId(), v.getDeptId())).findFirst().ifPresent(i -> { - v.setWalletPrice(BigDecimal.valueOf(i.getSum())); - v.setWalletCount(i.getCount()); - }); - } - if (ObjectUtil.isNotEmpty(walletReduceList)) { - walletReduceList.stream().filter(i -> ObjectUtil.equals(i.getDeptId(), v.getDeptId())).findFirst().ifPresent(i -> { - v.setWalletReducePrice(Math.abs(i.getSum())); - v.setWalletReduceCount(i.getCount()); - }); - } - // 退还各自食堂 - if (ObjectUtil.isNotEmpty(walletRetrunCanteenList)) { - walletRetrunCanteenList.stream().filter(i -> ObjectUtil.equals(i.getDeptId(), v.getDeptId())).findFirst().ifPresent(i -> { - v.setWalletReturnCanteenPrice(BigDecimal.valueOf(Math.abs(i.getSum()))); - v.setWalletReturnCanteenCount(i.getCount()); - }); - } - if (ObjectUtil.isNotEmpty(walletOnceBatchList)) { - walletOnceBatchList.stream().filter(i -> ObjectUtil.equals(i.getDeptId(), v.getDeptId())).findFirst().ifPresent(i -> { - v.setWalletOncePrice(BigDecimal.valueOf(i.getSum())); - v.setWalletOnceCount(i.getCount()); - }); - } - if (ObjectUtil.isNotEmpty(walletOnceReduceList)) { - walletOnceReduceList.stream().filter(i -> ObjectUtil.equals(i.getDeptId(), v.getDeptId())).findFirst().ifPresent(i -> { - v.setWalletOnceReducePrice(BigDecimal.valueOf(Math.abs(i.getSum()))); - v.setWalletOnceReduceCount(i.getCount()); - }); - } - if (ObjectUtil.isNotEmpty(payList)) { - payList.stream().filter(i -> ObjectUtil.equals(i.getDeptId(), v.getDeptId())).findFirst().ifPresent(i -> { - v.setOrderPayPrice(BigDecimal.valueOf(i.getSum())); - v.setOrderPayCount(i.getCount()); - }); - } - if (ObjectUtil.isNotEmpty(refundList)) { - refundList.stream().filter(i -> ObjectUtil.equals(i.getDeptId(), v.getDeptId())).findFirst().ifPresent(i -> { - v.setOrderRefundPrice(BigDecimal.valueOf(i.getSum())); - v.setOrderRefundCont(i.getCount()); - }); + R> response = remoteDeptService.getInnerAllDept(deptDto); + if (response == null || !response.isOk()) { + throw new IllegalStateException("查询部门交易统计的部门信息失败"); + } + return response.getData() == null ? List.of() : response.getData(); + } + + /** + * 构建钱包部门统计条件,保证五类钱包统计使用完全一致的权限、时间和钱包类型范围。 + * + * @param dto 部门统计页面筛选条件 + * @param bizType 钱包流水业务类型编码 + * @param type 收支方向;为空时不限制流水正负方向 + * @return 钱包流水汇总条件 + */ + private WalletTransactionSumAndCountDto buildWalletStatisticsDto(HardDeptTotalDto dto, String bizType, Integer type) { + WalletTransactionSumAndCountDto walletDto = new WalletTransactionSumAndCountDto(); + walletDto.setDeptIds(dto.getDeptIds()); + walletDto.setIsInclude(dto.getIsInclude()); + walletDto.setBizTypes(List.of(bizType)); + walletDto.setType(type); + walletDto.setCreateTimeList(dto.getCreateTimeList()); + walletDto.setWalletTypeId(dto.getWalletTypeId()); + return walletDto; + } + + /** + * 调用钱包服务查询部门流水统计;远程业务失败时终止本次汇总,避免把失败数据误展示为零。 + * + * @param walletDto 钱包流水汇总条件 + * @return 钱包部门统计,远程响应数据为空时返回空列表 + * @throws IllegalStateException 钱包流水远程调用失败时抛出 + */ + private List queryWalletStatistics(WalletTransactionSumAndCountDto walletDto) { + R> response = remoteWalletTransactionService.sumAndCount(walletDto); + if (response == null || !response.isOk()) { + throw new IllegalStateException("查询钱包部门交易统计失败"); + } + return response.getData() == null ? List.of() : response.getData(); + } + + /** + * 构建硬件订单部门统计条件。退款按售后完成时间统计,并覆盖支付、核销及整单退款状态, + * 因为部分退款不会把原订单状态改成整单退款。 + * + * @param dto 部门统计页面筛选条件 + * @param refund 是否查询已完成退款 + * @return 支付或退款汇总条件 + */ + private HardOrderSumAndCountDto buildOrderStatisticsDto(HardDeptTotalDto dto, boolean refund) { + HardOrderSumAndCountDto orderDto = new HardOrderSumAndCountDto(); + orderDto.setUserDeptIds(dto.getDeptIds()); + orderDto.setIsInclude(dto.getIsInclude()); + orderDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice()); + orderDto.setWalletTypeId(dto.getWalletTypeId()); + if (refund) { + orderDto.setRefundStatusCodes(List.of(HardSaleStatusEnum.COMPLETE.getStatus())); + orderDto.setRefundTimeList(dto.getCreateTimeList()); + } else { + orderDto.setCreateTimeList(dto.getCreateTimeList()); + } + return orderDto; + } + + /** + * 将钱包统计按部门建立索引,避免每个部门重复遍历全部统计结果。 + * + * @param statisticsList 钱包部门统计列表 + * @return 以部门 ID 为键的统计索引 + */ + private Map indexWalletStatistics(List statisticsList) { + Map statisticsMap = new LinkedHashMap<>(); + if (ObjectUtil.isNotEmpty(statisticsList)) { + for (WalletTransactionSumAndCountVo statistics : statisticsList) { + if (statistics != null && statistics.getDeptId() != null) { + statisticsMap.put(statistics.getDeptId(), statistics); + } } } - // 计算净流水 - for (HardDeptTotalVo v : resultList) { - // 流水金额 - if (ObjectUtil.isNotEmpty(v.getOrderPayPrice())) { - v.setOrderProfitPrice(v.getOrderPayPrice().subtract(v.getOrderRefundPrice())); - } - // 流水次数 - if (ObjectUtil.isNotEmpty(v.getOrderPayCount())) { - v.setOrderProfitCont(v.getOrderPayCount() - v.getOrderRefundCont()); + return statisticsMap; + } + + /** + * 将订单统计按部门建立索引,供支付和退款结果按相同键合并。 + * + * @param statisticsList 订单部门统计列表 + * @return 以部门 ID 为键的统计索引 + */ + private Map indexOrderStatistics(List statisticsList) { + Map statisticsMap = new LinkedHashMap<>(); + if (ObjectUtil.isNotEmpty(statisticsList)) { + for (HardOrderSumAndCountVo statistics : statisticsList) { + if (statistics != null && statistics.getDeptId() != null) { + statisticsMap.put(statistics.getDeptId(), statistics); + } } } - return resultList; + return statisticsMap; + } + + /** + * 合并部门的订单支付与已完成退款,并按原接口口径计算净金额和净次数。 + * + * @param target 部门统计结果 + * @param pay 支付统计,允许为空 + * @param refund 已完成售后退款统计,允许为空 + */ + private void applyOrderStatistics(HardDeptTotalVo target, HardOrderSumAndCountVo pay, HardOrderSumAndCountVo refund) { + long payPrice = pay == null ? 0L : ObjectUtil.defaultIfNull(pay.getSum(), 0L); + long payCount = pay == null ? 0L : ObjectUtil.defaultIfNull(pay.getCount(), 0L); + long refundPrice = refund == null ? 0L : ObjectUtil.defaultIfNull(refund.getSum(), 0L); + long refundCount = refund == null ? 0L : ObjectUtil.defaultIfNull(refund.getCount(), 0L); + BigDecimal orderPayPrice = BigDecimal.valueOf(payPrice); + BigDecimal orderRefundPrice = BigDecimal.valueOf(refundPrice); + target.setOrderPayPrice(orderPayPrice); + target.setOrderPayCount(payCount); + target.setOrderRefundPrice(orderRefundPrice); + target.setOrderRefundCont(refundCount); + target.setOrderProfitPrice(orderPayPrice.subtract(orderRefundPrice)); + target.setOrderProfitCont(payCount - refundCount); } /** diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml index ec7fddf7..978aed98 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml @@ -656,9 +656,10 @@ and r.audit_time between #{dto.refundTimeList[0]} and #{dto.refundTimeList[1]} - + + and r.status in - + #{v} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteMchService.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteMchService.java index 56595767..5fdca61a 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteMchService.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteMchService.java @@ -14,60 +14,190 @@ import org.springframework.web.bind.annotation.RequestParam; import java.util.List; +/** + * 钱包服务商户管理远程调用接口。 + * + *

用于跨服务查询商户详情、部门与商户映射关系,以及当前用户可管理的商户范围。 + * 调用方必须同时检查 {@link R} 的业务状态和数据内容,不能仅以 Feign 调用未抛异常作为成功依据。

+ */ @FeignClient(contextId = "remoteMchService", value = ServiceNameConstants.WALLET_SERVICE) public interface RemoteMchService { + /** + * 按商户条件查询列表,不应用当前登录用户的数据权限。 + * + * @param body 商户筛选条件,可按主键、部门、类型、父商户等字段筛选 + * @return 商户列表响应;未匹配到数据时响应成功且数据可能为空 + */ @NoToken @PostMapping("/mch-manage/list") R> list(@RequestBody MchManageDto body); + /** + * 按商户条件查询当前登录用户数据权限范围内的商户列表。 + * + * @param body 商户筛选条件,可按主键、部门、类型、父商户等字段筛选 + * @return 权限范围内的商户列表响应;未匹配到数据时响应成功且数据可能为空 + */ @PostMapping("/mch-manage/scope-list") R> scopeList(@RequestBody MchManageDto body); + /** + * 按多个商户类型编码查询商户列表。 + * + * @param types 商户类型持久化编码集合,对应 {@link MerchantTypeEnum#getType()} + * @return 匹配的商户列表响应;没有匹配商户时返回业务失败响应 + */ @NoToken @GetMapping("/mch-manage/get-by-types") R> getByTypes(@RequestParam List types); + /** + * 按商户主键查询商户完整信息。 + * + *

返回内容包含部门名称、启用的支付配置以及钱包别名等关联信息。

+ * + * @param id 商户主键 + * @return 商户详情响应;商户不存在时返回业务失败响应 + */ @NoToken @GetMapping("/mch-manage/get-by-id") R getById(@RequestParam Long id); + /** + * 查询指定父商户的直属子商户。 + * + * @param parentId 父商户主键 + * @return 直属子商户列表响应;没有子商户时返回成功的空集合 + */ @NoToken @GetMapping("/mch-manage/get-by-parent-id") R> getByParentId(@RequestParam Long parentId); + /** + * 按商户主键集合批量查询商户。 + * + * @param ids 商户主键集合 + * @return 匹配的商户列表响应;没有匹配商户时返回业务失败响应 + */ @NoToken @GetMapping("/mch-manage/get-by-ids") R> getByIds(@RequestParam List ids); + /** + * 查询直接绑定在指定部门上的指定类型商户。 + * + *

该方法只匹配商户自身绑定的部门,不沿部门父级或子级关系查找。

+ * + * @param deptId 商户直接绑定的系统部门主键 + * @param type 商户类型枚举 + * @return 匹配的商户响应;商户不存在时返回业务失败响应 + */ @NoToken @GetMapping("/mch-manage/get-by-dept-id") R getByDeptId(@RequestParam("deptId") Long deptId, @RequestParam("type") MerchantTypeEnum type); + /** + * 按商户直接绑定的部门主键集合批量查询商户。 + * + * @param deptIds 系统部门主键集合 + * @return 匹配的商户列表响应;没有匹配商户时返回业务失败响应 + */ @NoToken @GetMapping("/mch-manage/get-by-dept-ids") R> getByDeptIds(@RequestParam List deptIds); + /** + * 按传入部门顺序查询第一个直接绑定且类型匹配的商户。 + * + *

该方法不会自行查询部门层级,调用方需要传入已经按业务优先级排列的部门集合。

+ * + * @param deptIds 待匹配的系统部门主键集合,集合顺序决定商户匹配优先级 + * @param mchType 允许匹配的商户类型 + * @return 首个匹配商户的响应;没有匹配商户时返回业务失败响应 + */ @NoToken @GetMapping("/mch-manage/get-first-mch") R getFirstMch(@RequestParam List deptIds, @RequestParam MerchantTypeEnum mchType); + /** + * 沿指定部门的父级链查询第一个类型匹配的商户。 + * + *

服务端先解析部门父级链,再按部门链顺序选择直接绑定的商户;返回内容包含启用的支付配置。

+ * + * @param deptId 起始系统部门主键 + * @param mchType 允许匹配的商户类型 + * @return 首个匹配商户的响应;没有匹配商户时返回业务失败响应 + */ @NoToken @GetMapping("/mch-manage/get-first-mch-by-dept-id") R getFirstMchByDeptId(@RequestParam Long deptId, @RequestParam MerchantTypeEnum mchType); + /** + * 通过商户部门关系视图查询指定部门归属的食堂商户。 + * + *

可从商户绑定部门的下级部门反向定位食堂商户,并优先返回关系层级最近的商户; + * 返回内容包含启用的支付配置。

+ * + * @param deptId 待解析归属关系的系统部门主键 + * @return 归属食堂商户响应;不存在归属食堂时返回业务失败响应 + */ @NoToken @GetMapping("/mch-manage/get-views-canteen-mch-by-dept-id") R getViewsCanteenMchByDeptId(@RequestParam Long deptId); + /** + * 通过商户部门关系视图查询指定部门归属的指定类型商户。 + * + *

可从商户绑定部门的下级部门反向定位商户,并优先返回关系层级最近的匹配项; + * 返回内容包含启用的支付配置。

+ * + * @param deptId 待解析归属关系的系统部门主键 + * @param mchTypeList 允许匹配的商户类型集合 + * @return 归属商户响应;不存在匹配商户时返回业务失败响应 + */ @NoToken @GetMapping("/mch-manage/get-views-mch-by-dept-id") R getViewsMchByDeptId(@RequestParam Long deptId, @RequestParam List mchTypeList); + /** + * 查询指定食堂商户覆盖的用户部门主键集合。 + * + *

服务端以商户直接绑定部门及其下级部门为候选范围,再通过商户部门关系视图筛选 + * 与该商户类型关联的用户部门。商户不存在或没有关联部门时返回业务失败响应。

+ * + * @param mchId 食堂商户主键 + * @return 去重后的用户部门主键集合响应 + */ @NoToken @GetMapping("/mch-manage/get-self-canteen-user-dept-id-list") R> getSelfCanteenUserDeptIdList(@RequestParam Long mchId); + /** + * 查询指定用户所属目标类型商户覆盖的用户部门主键集合。 + * + *

钱包服务内部先根据用户所属部门和商户类型定位商户,再复用商户部门范围查询逻辑, + * 调用方只需发起一次远程调用。用户、所属部门、匹配商户或关联部门不存在时返回业务失败响应。

+ * + * @param userId 系统用户主键 + * @param mchTypeList 允许匹配的商户类型集合 + * @return 去重后的目标商户用户部门主键集合响应 + */ + @NoToken + @GetMapping("/mch-manage/get-self-mch-user-dept-id-list-by-user-id") + R> getSelfMchUserDeptIdListByUserId(@RequestParam Long userId, @RequestParam List mchTypeList); + + /** + * 查询指定用户可管理的指定类型商户主键集合。 + * + *

服务端以用户所属部门及其下级部门作为权限范围,通过商户部门关系视图匹配商户。 + * 当用户信息不存在或没有匹配商户时,当前实现返回包含 {@code -1L} 的占位集合,调用方 + * 不应将该值作为有效商户主键使用。

+ * + * @param userId 系统用户主键 + * @param mchTypeList 允许匹配的商户类型集合 + * @return 用户可管理的商户主键集合响应;无匹配数据时可能仅包含 {@code -1L} + */ @NoToken @GetMapping("/mch-manage/get-self-mch-id-list") R> getSelfMchIdList(@RequestParam Long userId, @RequestParam List mchTypeList); diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchManageController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchManageController.java index 7893cfdc..13043fec 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchManageController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchManageController.java @@ -160,6 +160,22 @@ public class MchManageController { return R.ok(userDeptIdList); } + /** + * 查询指定用户所属目标类型商户覆盖的用户部门主键集合。 + * + * @param userId 系统用户主键 + * @param mchTypeList 允许匹配的商户类型集合 + * @return 目标商户覆盖的用户部门主键集合;无法定位用户、匹配商户或关联部门时返回业务失败响应 + */ + @Inner + @GetMapping("/get-self-mch-user-dept-id-list-by-user-id") + public R> getSelfMchUserDeptIdListByUserId(@RequestParam Long userId, + @RequestParam List mchTypeList) { + List userDeptIdList = mchManageService.getSelfMchUserDeptIdListByUserId(userId, mchTypeList); + if (ObjectUtil.isEmpty(userDeptIdList)) return R.failed("商户不存在"); + return R.ok(userDeptIdList); + } + @Inner @GetMapping("/get-self-mch-id-list") public R> getSelfMchIdList(@RequestParam Long userId, @RequestParam List mchTypeList) { diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchManageService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchManageService.java index 72add9ef..f83350da 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchManageService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchManageService.java @@ -72,6 +72,15 @@ public interface MchManageService extends IService { */ List getSelfCanteenUserDeptIdList(Long mchId); + /** + * 根据用户主键和商户类型查询所属商户覆盖的用户部门主键集合。 + * + * @param userId 系统用户主键 + * @param mchTypeList 允许匹配的商户类型集合 + * @return 目标商户覆盖的用户部门主键集合;用户、匹配商户或关联部门不存在时返回空集合 + */ + List getSelfMchUserDeptIdListByUserId(Long userId, List mchTypeList); + /** * 判断用户是否能查看到所有食堂 */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchManageServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchManageServiceImpl.java index 27fe654a..864f398e 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchManageServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchManageServiceImpl.java @@ -560,6 +560,33 @@ public class MchManageServiceImpl extends ServiceImpl先通过用户所属部门和允许的商户类型定位最近的匹配商户,再复用按商户查询覆盖部门的逻辑, + * 将原本由调用方完成的两段查询收敛到钱包服务内部。

+ * + * @param userId 系统用户主键 + * @param mchTypeList 允许匹配的商户类型集合 + * @return 目标商户覆盖的用户部门主键集合;用户、所属部门、匹配商户或关联部门不存在时返回空集合 + */ + @Override + public List getSelfMchUserDeptIdListByUserId(Long userId, List mchTypeList) { + if (userId == null || ObjectUtil.isEmpty(mchTypeList)) { + return List.of(); + } + R userResponse = remoteUserService.getInnerByUserId(userId); + if (userResponse == null || !userResponse.isOk() || userResponse.getData() == null + || userResponse.getData().getSysUser() == null || userResponse.getData().getSysUser().getDeptId() == null) { + return List.of(); + } + MchManageVo merchant = getViewsCanteenMch(userResponse.getData().getSysUser().getDeptId(), mchTypeList); + if (merchant == null || merchant.getId() == null) { + return List.of(); + } + return getSelfCanteenUserDeptIdList(merchant.getId()); + } + /** * 判断用户是否能查看到所有食堂 */ From d508e3f9598ad19970d5f896cdb5dfa9f75564ad Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 27 Aug 2026 22:03:54 +0800 Subject: [PATCH 16/31] 11 --- .../api/vo/order/HardCanteenUserApptDishStatisticsVo.java | 2 +- .../vo/order/HardCanteenUserApptStatisticsSummaryVo.java | 2 +- .../impl/order/HardCanteenApptTotalServiceImpl.java | 8 +++++--- 3 files changed, 7 insertions(+), 5 deletions(-) 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 index 581e8637..4de7cf12 100644 --- 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 @@ -21,7 +21,7 @@ public class HardCanteenUserApptDishStatisticsVo { @Schema(description = "菜品名称") private String productName; - /** 当前同餐段、同名菜品统计项实际涉及的商品 ID;同名商品可能因重复建档包含多个 ID,历史空值不写入集合。 */ + /** 当前菜品统计项实际涉及的商品 ID;正常数据仅有一个,历史商品 ID 为空时不写入集合。 */ @Schema(description = "商品ID集合") private List productIds = new ArrayList<>(); 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 index 0c85a200..88ebbaea 100644 --- 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 @@ -47,7 +47,7 @@ public class HardCanteenUserApptStatisticsSummaryVo { @Schema(description = "餐段统计") private List meals = new ArrayList<>(); - /** 按餐段和菜品名称聚合的菜品份数。 */ + /** 按餐段和商品 ID 聚合的菜品份数;历史商品 ID 缺失时按菜品名称兜底。 */ @Schema(description = "菜品统计") private List dishes = new ArrayList<>(); } 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 9bdd4116..4d7c06eb 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 @@ -1076,7 +1076,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ /** 餐段编码到餐段聚合结果的有序映射。 */ private final Map meals = new LinkedHashMap<>(); - /** 餐段和菜品名称到菜品聚合结果的有序映射。 */ + /** 餐段和商品身份到菜品聚合结果的有序映射;商品 ID 缺失时使用名称兼容历史数据。 */ private final Map dishes = new LinkedHashMap<>(); /** @@ -1120,7 +1120,9 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ dishCount += count; meal.dishCount += count; String productName = normalize(car.getProductName(), "未命名菜品"); - String dishKey = hourType + "\u0000" + productName; + // 同一商品的历史名称快照可能带有空格或不可见字符,优先按商品 ID 聚合可避免页面出现同 ID 重复行。 + String productKey = car.getProductId() == null ? "name:" + productName : "id:" + car.getProductId(); + String dishKey = hourType + "\u0000" + productKey; DishStatisticsAccumulator dish = dishes.computeIfAbsent(dishKey, key -> new DishStatisticsAccumulator(hourType, productName, car.getProductPic())); if (car.getProductId() != null) { @@ -1247,7 +1249,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ /** 下单时固化的菜品名称。 */ private final String productName; - /** 同餐段、同名菜品实际涉及的商品 ID,保留首次出现顺序并去重,便于排查同名商品重复建档。 */ + /** 当前统计项实际涉及的商品 ID,正常数据仅有一个;集合结构兼容历史无 ID 的名称兜底聚合。 */ private final Set productIds = new LinkedHashSet<>(); /** 下单时固化的菜品图片地址;同名菜品聚合时保留首个非空值。 */ From 9b66d2f99f173e8a2ce303b407a83e4878ebd2c0 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 27 Aug 2026 22:12:17 +0800 Subject: [PATCH 17/31] 11 --- .../src/main/resources/mapper/order/HardCarMapper.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9e25594e..282afedb 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 @@ -973,7 +973,7 @@
group by a.product_id - order by a.product_id desc, o.order_type asc, o.hour_type asc, SUM(a.product_count) desc + order by a.product_name asc, o.order_type asc, o.hour_type asc, SUM(a.product_count) desc From f8cf7c1aa9c4aa094f8276e34b53ac8cb9c9b023 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 27 Aug 2026 22:21:35 +0800 Subject: [PATCH 18/31] 11 --- .../com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java | 2 +- .../src/main/resources/mapper/order/HardCarMapper.xml | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) 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 26a42a9f..e5fbe1cf 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 @@ -96,7 +96,7 @@ public interface HardCarMapper extends PjcloudBaseMapper { * * @param page 分页参数 * @param dto 单日、来源、食堂、餐段、部门和商品名称筛选 - * @return 按商品聚合的来源明细页 + * @return 按商品名称、商品 ID、统计日期和来源升序排列的商品聚合明细页 */ Page proudctQueryStats(Page page, @Param("dto") HardCarDto dto); 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 282afedb..78fe0bb9 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 @@ -973,10 +973,10 @@ group by a.product_id - order by a.product_name asc, o.order_type asc, o.hour_type asc, SUM(a.product_count) desc + order by a.product_id desc, o.order_type asc, o.hour_type asc, SUM(a.product_count) desc - +