From 6e1af7c84d84e4ff6bf973c0448fff96d861fca4 Mon Sep 17 00:00:00 2001
From: dzc <2388224167@qq.com>
Date: Fri, 14 Aug 2026 15:03:58 +0800
Subject: [PATCH] =?UTF-8?q?fix:=E4=BF=AE=E6=94=B9=E7=94=A8=E6=88=B7?=
=?UTF-8?q?=E7=AE=A1=E7=90=86=E6=A0=B7=E5=BC=8F=EF=BC=8C=E8=83=BD=E5=B1=95?=
=?UTF-8?q?=E7=A4=BA=E7=94=A8=E6=88=B7=E8=BA=AB=E4=BB=BD=E4=BF=A1=E6=81=AF?=
=?UTF-8?q?=E3=80=82=20fix:=E4=BF=AE=E6=94=B9=E6=A0=91=E7=8A=B6=E6=9F=A5?=
=?UTF-8?q?=E8=AF=A2=E8=AE=A9=E4=BB=96=E6=9B=B4=E7=B4=A7=E5=87=91=E3=80=82?=
=?UTF-8?q?=20fix:=E4=BF=AE=E6=94=B9app=E5=BE=AE=E4=BF=A1=E7=99=BB?=
=?UTF-8?q?=E5=BD=95=EF=BC=8C=E5=85=88=E6=A3=80=E6=B5=8B=E7=94=A8=E6=88=B7?=
=?UTF-8?q?=E6=89=8B=E6=9C=BA=E4=B8=8A=E6=B2=A1=E6=9C=89=E6=9C=89=E8=A3=85?=
=?UTF-8?q?=E5=BE=AE=E4=BF=A1=EF=BC=8C=E6=B2=A1=E6=9C=89=E5=B0=B1=E9=9A=90?=
=?UTF-8?q?=E8=97=8F=E5=BE=AE=E4=BF=A1=E7=99=BB=E5=BD=95=E3=80=82=20fix:?=
=?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=95=86=E6=88=B7=E7=AE=A1=E7=90=86"?=
=?UTF-8?q?=E5=95=86=E6=88=B7=E5=8A=9F=E8=83=BD"=E6=A0=B7=E5=BC=8F=20feat:?=
=?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=87=AA=E5=8A=A8=E9=80=80=E6=AC=BE=E6=A8=A1?=
=?UTF-8?q?=E5=9D=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
db/20260814_add_hard_auto_refund.sql | 148 +++
.../refund/HardAfterSaleOperateTypeEnum.java | 34 +-
.../HardAutoRefundAttemptResultEnum.java | 51 +
.../refund/HardAutoRefundTaskStatusEnum.java | 73 ++
.../refund/HardAutoRefundTriggerTypeEnum.java | 53 +
.../dto/order/HardAutoRefundTaskPageDto.java | 57 +
...ardAppCreateDailyMenuReserveOrdersDto.java | 6 +-
.../app/HardAppCreateReserveOrdersDto.java | 7 +
.../dto/order/app/HardAppRefundApplyDto.java | 2 +-
.../order/HardAutoRefundAttemptLogEntity.java | 141 +++
.../order/HardAutoRefundTaskEntity.java | 159 +++
.../api/entity/order/HardOrderEntity.java | 18 +
.../hard/api/vo/order/HardAfterSaleVo.java | 5 +
.../api/vo/order/HardAutoRefundTaskVo.java | 45 +
.../vo/order/app/HardAppAutoRefundTaskVo.java | 44 +
.../vo/order/app/HardAppRefundApplyVo.java | 36 +
.../order/HardAutoRefundTaskController.java | 155 +++
.../controller/order/HardOrderController.java | 4 +-
.../hard/job/HardAutoRefundTaskJob.java | 57 +
.../order/HardAutoRefundAttemptLogMapper.java | 12 +
.../order/HardAutoRefundTaskMapper.java | 51 +
.../hard/service/HardAfterSaleService.java | 8 +
.../service/HardApptReservationService.java | 13 +-
.../service/HardAutoRefundTaskService.java | 110 ++
.../impl/HardApptReservationServiceImpl.java | 4 +-
.../impl/order/HardAfterSaleServiceImpl.java | 159 ++-
.../order/HardAutoRefundTaskServiceImpl.java | 972 ++++++++++++++++++
.../impl/order/HardOrderServiceImpl.java | 379 +++++--
.../mapper/order/HardOrderMapper.xml | 3 +
.../hard/job/HardAutoRefundTaskJobTest.java | 62 ++
.../HardApptReservationServiceImplTest.java | 6 +-
.../HardAutoRefundTaskServiceImplTest.java | 180 ++++
.../2026-06/debug.2026-06-01.0.log.gz | Bin 0 -> 4210 bytes
.../admin/service/SysUserIdentityService.java | 23 +-
.../service/impl/SysUserServiceImpl.java | 9 +-
.../impl/user/SysUserIdentityServiceImpl.java | 65 +-
.../mch/MchAutoRefundRuleTypeEnum.java | 49 +
.../mch/MchAutoRefundConfigBatchQueryDto.java | 30 +
.../api/dto/mch/MchAutoRefundConfigDto.java | 54 +
.../dto/recharge/WalletRechargeRefundDto.java | 26 +
.../entity/mch/MchAutoRefundConfigEntity.java | 111 ++
.../RemoteMchAutoRefundConfigService.java | 42 +
.../feign/RemoteWalletRechargeService.java | 19 +-
.../wallet/api/feign/RemoteWalletService.java | 12 +
.../feign/RemoteWalletTransactionService.java | 21 +
.../api/vo/mch/MchAutoRefundConfigVo.java | 15 +
.../app/GuardianWardRechargeItemVo.java | 5 +
...mework.cloud.openfeign.FeignClient.imports | 1 +
.../controller/WalletManageController.java | 14 +
.../WalletTransactionController.java | 25 +
.../mch/MchAutoRefundConfigController.java | 96 ++
.../recharge/WalletRechargeController.java | 16 +
.../wallet/mapper/WalletManageMapper.java | 5 +-
.../mapper/mch/MchAutoRefundConfigMapper.java | 39 +
.../service/MchAutoRefundConfigService.java | 65 ++
.../wallet/service/WalletManageService.java | 11 +-
.../wallet/service/WalletRechargeService.java | 12 +-
.../service/WalletTransactionService.java | 17 +
.../service/impl/WalletManageServiceImpl.java | 27 +-
.../impl/WalletTransactionServiceImpl.java | 353 +++++++
.../mch/MchAutoRefundConfigServiceImpl.java | 254 +++++
.../recharge/WalletRechargeServiceImpl.java | 61 +-
.../MchAutoRefundConfigServiceImplTest.java | 131 +++
.../WalletRechargeServiceImplTest.java | 220 ++++
64 files changed, 4748 insertions(+), 134 deletions(-)
create mode 100644 db/20260814_add_hard_auto_refund.sql
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundAttemptResultEnum.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskStatusEnum.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAutoRefundTaskPageDto.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundAttemptLogEntity.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppAutoRefundTaskVo.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJob.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundAttemptLogMapper.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJobTest.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/logs/pjcloud-thirdparty-biz/2026-06/debug.2026-06-01.0.log.gz
create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/mch/MchAutoRefundRuleTypeEnum.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigBatchQueryDto.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigDto.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchAutoRefundConfigEntity.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteMchAutoRefundConfigService.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchAutoRefundConfigVo.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchAutoRefundConfigController.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchAutoRefundConfigMapper.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchAutoRefundConfigService.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchAutoRefundConfigServiceImpl.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchAutoRefundConfigServiceImplTest.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImplTest.java
diff --git a/db/20260814_add_hard_auto_refund.sql b/db/20260814_add_hard_auto_refund.sql
new file mode 100644
index 000000000..80a960506
--- /dev/null
+++ b/db/20260814_add_hard_auto_refund.sql
@@ -0,0 +1,148 @@
+-- 每日菜谱预约订单自动退款。
+-- 本脚本只新增自动退款所需表、订单快照字段、钱包幂等索引和技术重试权限点,
+-- 不回填历史订单,不创建跨库外键,也不自动向已有角色授予技术重试权限。
+
+-- 执行唯一索引变更前应确认本查询返回 0 行;发现重复时必须先人工核对资金记录,禁止自动清理。
+SELECT pay_refund_order_id, COUNT(*) AS duplicate_count
+FROM pjcloudx_wallet.wallet_recharge
+WHERE pay_refund_order_id IS NOT NULL
+GROUP BY pay_refund_order_id
+HAVING COUNT(*) > 1;
+
+-- Wallet按售后单ID保证退款到账幂等;MySQL唯一索引允许保留多条NULL历史记录。
+-- 将该变更放在其他DDL之前,重复检测不通过时由唯一索引直接阻止脚本继续执行。
+ALTER TABLE `pjcloudx_wallet`.`wallet_recharge`
+ ADD UNIQUE KEY `uk_wallet_recharge_refund_order` (`pay_refund_order_id`);
+
+-- 商户自动退款配置。父店和分店按各自 mch_id 独立配置,每个订单类型只保留一条配置记录。
+CREATE TABLE `pjcloudx_wallet`.`mch_auto_refund_config`
+(
+ `id` bigint(20) NOT NULL COMMENT '配置主键,由应用使用雪花算法生成',
+ `mch_id` bigint(20) NOT NULL COMMENT '商户ID,父店和分店互不继承配置',
+ `order_type` char(10) NOT NULL COMMENT '硬件订单类型编码,首期仅支持2预约订单',
+ `enabled` char(1) NOT NULL DEFAULT '0' COMMENT '自动退款开关,0关闭,1开启',
+ `rule_type` varchar(32) NOT NULL COMMENT '自动退款规则,PAY_TIME_LIMIT支付后限时,APPT_CYCLE_CLOSE周期预约截止',
+ `pay_time_limit_hours` int(11) DEFAULT NULL COMMENT '支付后可自动退款小时数,仅PAY_TIME_LIMIT使用,业务允许1至168',
+ `create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
+ `update_time` datetime DEFAULT NULL COMMENT '修改时间',
+ `del_flag` char(1) DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除;本功能不提供删除接口',
+ `tenant_id` bigint(20) NOT NULL COMMENT '租户ID',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_mch_auto_refund_config` (`tenant_id`, `mch_id`, `order_type`),
+ KEY `idx_mch_auto_refund_config_mch` (`tenant_id`, `mch_id`, `del_flag`)
+) ENGINE = InnoDB
+ DEFAULT CHARSET = utf8mb4
+ COLLATE = utf8mb4_general_ci
+ COMMENT = '商户自动退款配置';
+
+-- 订单保存下单时的配置与规则快照;三个关键字段任一为空均不具备免审资格,历史订单保持为空。
+ALTER TABLE `pjcloudx_hardware`.`hard_order`
+ ADD COLUMN `auto_refund_config_id` bigint(20) DEFAULT NULL
+ COMMENT '下单时命中的Wallet自动退款配置ID,为空表示不具备免审资格' AFTER `appt_rule_id`,
+ ADD COLUMN `auto_refund_rule_type` varchar(32) DEFAULT NULL
+ COMMENT '下单时自动退款规则快照,PAY_TIME_LIMIT或APPT_CYCLE_CLOSE' AFTER `auto_refund_config_id`,
+ ADD COLUMN `auto_refund_deadline` datetime DEFAULT NULL
+ COMMENT '免审退款申请截止时间,闭区间判断为申请时间小于等于该值' AFTER `auto_refund_rule_type`;
+
+-- 自动退款持久化任务。一个售后单最多一条任务,失败后由该记录驱动定时恢复。
+CREATE TABLE `pjcloudx_hardware`.`hard_auto_refund_task`
+(
+ `id` bigint(20) NOT NULL COMMENT '任务主键,由应用使用雪花算法生成',
+ `after_sale_id` bigint(20) NOT NULL COMMENT '售后单ID,同时作为钱包退款幂等键',
+ `order_id` bigint(20) NOT NULL COMMENT '原硬件订单ID,用于订单级加锁和退款状态汇总',
+ `mch_id` bigint(20) NOT NULL COMMENT '订单所属商户ID,用于数据范围和配置复核',
+ `config_id` bigint(20) NOT NULL COMMENT '下单时命中的Wallet自动退款配置ID',
+ `rule_type` varchar(32) NOT NULL COMMENT '自动退款规则快照,任务重试期间不随配置规则变化',
+ `eligibility_deadline` datetime NOT NULL COMMENT '申请免审资格截止时间,任务受理后跨过该时间仍继续重试',
+ `status` char(10) NOT NULL COMMENT '任务状态,10待执行,20等待重试,30开关关闭暂停,31配置不可用暂停,40重试耗尽,50成功',
+ `auto_attempt_count` int(11) NOT NULL DEFAULT 0 COMMENT '已消耗自动尝试次数,含首次执行,最大8次',
+ `total_attempt_count` int(11) NOT NULL DEFAULT 0 COMMENT '全部尝试序号计数,包含自动、人工和开关关闭跳过',
+ `next_retry_time` datetime DEFAULT NULL COMMENT '下次自动重试时间,暂停、耗尽和成功状态为空',
+ `last_attempt_time` datetime DEFAULT NULL COMMENT '最近一次实际尝试或开关关闭跳过的开始时间',
+ `success_time` datetime DEFAULT NULL COMMENT '钱包到账且售后与订单状态提交成功的时间',
+ `last_error` varchar(2000) DEFAULT NULL COMMENT '最近一次失败诊断信息,不保存资金凭证等敏感数据',
+ `create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
+ `update_time` datetime DEFAULT NULL COMMENT '修改时间',
+ `del_flag` char(1) DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除;任务不提供删除接口',
+ `tenant_id` bigint(20) NOT NULL COMMENT '租户ID',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_hard_auto_refund_task_after_sale` (`after_sale_id`),
+ KEY `idx_hard_auto_refund_task_scan` (`tenant_id`, `status`, `next_retry_time`, `del_flag`),
+ KEY `idx_hard_auto_refund_task_order` (`tenant_id`, `order_id`, `del_flag`)
+) ENGINE = InnoDB
+ DEFAULT CHARSET = utf8mb4
+ COLLATE = utf8mb4_general_ci
+ COMMENT = '硬件订单自动退款任务';
+
+-- 自动退款单次尝试日志。日志只在一次尝试内从执行中更新为最终结果,完成后只读。
+CREATE TABLE `pjcloudx_hardware`.`hard_auto_refund_attempt_log`
+(
+ `id` bigint(20) NOT NULL COMMENT '日志主键,由应用使用雪花算法生成',
+ `task_id` bigint(20) NOT NULL COMMENT '自动退款任务ID',
+ `after_sale_id` bigint(20) NOT NULL COMMENT '售后单ID,用于直接查询完整尝试历史',
+ `attempt_no` int(11) NOT NULL COMMENT '任务内递增尝试序号,包含开关关闭跳过记录',
+ `trigger_type` char(10) NOT NULL COMMENT '触发来源,10首次同步执行,20定时重试,30人工技术重试',
+ `result_status` char(10) NOT NULL COMMENT '尝试结果,10执行中,20成功,30失败,40因开关关闭跳过',
+ `refund_code` varchar(128) NOT NULL COMMENT '稳定钱包退款业务号,格式hard_{orderId}_{afterSaleId}',
+ `operator_user_id` bigint(20) DEFAULT NULL COMMENT '人工技术重试操作用户ID,自动触发为空',
+ `started_time` datetime NOT NULL COMMENT '本次尝试开始时间',
+ `finished_time` datetime DEFAULT NULL COMMENT '本次尝试完成时间,执行中记录为空',
+ `error_message` varchar(2000) DEFAULT NULL COMMENT '失败诊断信息,成功或开关关闭跳过时为空',
+ `create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
+ `update_time` datetime DEFAULT NULL COMMENT '修改时间',
+ `del_flag` char(1) DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除;日志不提供删除接口',
+ `tenant_id` bigint(20) NOT NULL COMMENT '租户ID',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_hard_auto_refund_attempt` (`task_id`, `attempt_no`),
+ KEY `idx_hard_auto_refund_attempt_after_sale` (`tenant_id`, `after_sale_id`, `create_time`)
+) ENGINE = InnoDB
+ DEFAULT CHARSET = utf8mb4
+ COLLATE = utf8mb4_general_ci
+ COMMENT = '硬件订单自动退款尝试日志';
+
+-- 为每个已有“售后订单-查看”权限所在租户注册技术重试按钮。
+-- 菜单ID从迁移日期种子或现有最大ID之后分配;脚本不写sys_role_menu,需由角色管理显式授权。
+SET @hard_auto_refund_menu_id_base = (
+ SELECT GREATEST(COALESCE(MAX(menu_id), 0), 2026081400000000000)
+ FROM pjcloudx.sys_menu
+);
+
+INSERT INTO `pjcloudx`.`sys_menu`
+(`menu_id`, `name`, `permission`, `path`, `component`, `parent_id`, `icon`, `visible`, `sort_order`,
+ `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `tenant_id`)
+SELECT @hard_auto_refund_menu_id_base + ROW_NUMBER() OVER (ORDER BY source_menu.tenant_id, source_menu.menu_id),
+ '自动退款重试',
+ 'hard_autoRefundTask_retry',
+ NULL,
+ NULL,
+ source_menu.parent_id,
+ '1',
+ '1',
+ COALESCE(source_menu.sort_order, 0) + 1,
+ '0',
+ NULL,
+ source_menu.menu_type,
+ 'system',
+ NOW(),
+ 'system',
+ NOW(),
+ '0',
+ source_menu.tenant_id
+FROM pjcloudx.sys_menu source_menu
+WHERE source_menu.permission = 'hard_hardAfterSale_view'
+ AND source_menu.del_flag = '0'
+ AND NOT EXISTS (
+ SELECT 1
+ FROM pjcloudx.sys_menu existing_menu
+ WHERE existing_menu.permission = 'hard_autoRefundTask_retry'
+ AND existing_menu.tenant_id <=> source_menu.tenant_id
+ AND existing_menu.del_flag = '0'
+);
+
+SET @hard_auto_refund_menu_id_base = NULL;
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java
index 53883c3e5..6e1edc418 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
@@ -1,26 +1,56 @@
package com.bsd.pjcloud.hard.api.constant.refund;
-import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 硬件售后操作类型的枚举
*/
-@AllArgsConstructor
@Getter
public enum HardAfterSaleOperateTypeEnum {
+ /** 会员主动提交退款申请。 */
MEMBER_CREATE("10", "会员申请退款"),
+
+ /** 商户审核同意退款申请。 */
ADMIN_AGREE_APPLY("11", "商家同意退款"),
+
+ /** 商户审核拒绝退款申请。 */
ADMIN_DISAGREE_APPLY("12", "商家拒绝退款"),
+
+ /** 会员补充退货物流信息。 */
MEMBER_DELIVERY("20", "会员填写退货物流信息,快递公司:{deliveryName},快递单号:{logisticsNo}"),
+
+ /** 商户确认收到退货商品。 */
ADMIN_AGREE_RECEIVE("21", "商家收货"),
+
+ /** 商户拒绝接收退货商品。 */
ADMIN_DISAGREE_RECEIVE("22", "商家拒绝收货,原因:{reason}"),
+
+ /** 商户或后台执行资金退款。 */
ADMIN_REFUND("30", "商家退款"),
+
+ /** 系统根据订单快照和商户配置免审完成退款。 */
+ SYSTEM_AUTO_REFUND("31", "系统自动退款"),
+
+ /** 会员取消尚未审核的退款申请。 */
MEMBER_CANCEL("40", "会员取消退款"),
;
+ /** 售后日志持久化的稳定操作类型编码。 */
private final String type;
+
+ /** 售后进度展示内容,部分人工操作允许包含格式化占位符。 */
private final String content;
+
+ /**
+ * 构造售后操作类型协议项。
+ *
+ * @param type 售后日志持久化编码
+ * @param content 售后进度展示内容
+ */
+ HardAfterSaleOperateTypeEnum(String type, String content) {
+ this.type = type;
+ this.content = content;
+ }
}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundAttemptResultEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundAttemptResultEnum.java
new file mode 100644
index 000000000..c643ddf4f
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundAttemptResultEnum.java
@@ -0,0 +1,51 @@
+package com.bsd.pjcloud.hard.api.constant.refund;
+
+import lombok.Getter;
+
+/**
+ * 自动退款单次尝试结果。日志先以执行中写入同一事务,最终持久化为成功、失败或开关关闭跳过。
+ */
+@Getter
+public enum HardAutoRefundAttemptResultEnum {
+
+ /**
+ * 资金调用开始但尚未完成的临时状态;正常提交后不会长期停留。
+ */
+ EXECUTING("10", "执行中"),
+
+ /**
+ * 钱包到账及 Hardware 状态更新均已提交。
+ */
+ SUCCESS("20", "成功"),
+
+ /**
+ * 本次实际资金调用失败,已安排重试或进入耗尽状态。
+ */
+ FAILED("30", "失败"),
+
+ /**
+ * 执行前发现当前开关关闭,未调用资金接口且不消耗自动尝试额度。
+ */
+ SWITCH_DISABLED("40", "因开关关闭跳过");
+
+ /**
+ * 数据库存储的稳定结果编码。
+ */
+ private final String status;
+
+ /**
+ * 管理端展示名称。
+ */
+ private final String description;
+
+ /**
+ * 构造一次尝试结果协议项。
+ *
+ * @param status 数据库存储的稳定结果编码
+ * @param description 管理端展示名称
+ */
+ HardAutoRefundAttemptResultEnum(String status, String description) {
+ this.status = status;
+ this.description = description;
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskStatusEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskStatusEnum.java
new file mode 100644
index 000000000..936d68ac5
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskStatusEnum.java
@@ -0,0 +1,73 @@
+package com.bsd.pjcloud.hard.api.constant.refund;
+
+import lombok.Getter;
+
+import java.util.Arrays;
+
+/**
+ * 自动退款任务状态。编码持久化到 hard_auto_refund_task.status,并由 PC、App 共同展示。
+ */
+@Getter
+public enum HardAutoRefundTaskStatusEnum {
+
+ /**
+ * 售后申请事务已经创建任务,等待事务提交后的首次同步执行。
+ */
+ PENDING("10", "待执行"),
+
+ /**
+ * 上次资金调用失败,等待 next_retry_time 到期后自动重试。
+ */
+ WAITING_RETRY("20", "等待重试"),
+
+ /**
+ * 商户已关闭自动退款,任务保留并等待重新开启。
+ */
+ SWITCH_DISABLED("30", "开关关闭暂停"),
+
+ /**
+ * 配置缺失、规则不一致或配置服务异常,任务保留并等待配置恢复。
+ */
+ CONFIG_UNAVAILABLE("31", "配置不可用暂停"),
+
+ /**
+ * 首次执行和七次自动重试均失败,只允许技术人员额外手工触发。
+ */
+ RETRY_EXHAUSTED("40", "重试耗尽"),
+
+ /**
+ * 钱包已幂等到账,售后和订单退款聚合状态均已完成更新。
+ */
+ SUCCESS("50", "成功");
+
+ /**
+ * 数据库和接口使用的稳定状态编码。
+ */
+ private final String status;
+
+ /**
+ * 管理端展示名称。
+ */
+ private final String description;
+
+ /**
+ * 构造自动退款任务状态协议项。
+ *
+ * @param status 数据库和接口使用的稳定状态编码
+ * @param description 管理端展示名称
+ */
+ HardAutoRefundTaskStatusEnum(String status, String description) {
+ this.status = status;
+ this.description = description;
+ }
+
+ /**
+ * 按持久化编码解析任务状态,未知值返回 {@code null}。
+ *
+ * @param status 数据库存储编码
+ * @return 匹配状态;未知时返回 {@code null}
+ */
+ public static HardAutoRefundTaskStatusEnum valueOfStatus(String status) {
+ return Arrays.stream(values()).filter(item -> item.status.equals(status)).findFirst().orElse(null);
+ }
+}
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
new file mode 100644
index 000000000..31e6da140
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java
@@ -0,0 +1,53 @@
+package com.bsd.pjcloud.hard.api.constant.refund;
+
+import lombok.Getter;
+
+/**
+ * 自动退款尝试触发来源。编码持久化到尝试日志,属于管理端审计协议。
+ */
+@Getter
+public enum HardAutoRefundTriggerTypeEnum {
+
+ /**
+ * 售后申请本地事务提交后的首次同步执行。
+ */
+ INITIAL("10", "首次同步执行", true),
+
+ /**
+ * 每 30 秒调度器触发的到期自动重试。
+ */
+ SCHEDULED("20", "定时重试", true),
+
+ /**
+ * 管理端技术人员对耗尽任务执行的一次额外尝试。
+ */
+ MANUAL("30", "人工技术重试", false);
+
+ /**
+ * 数据库存储的稳定触发编码。
+ */
+ private final String type;
+
+ /**
+ * 管理端展示名称。
+ */
+ private final String description;
+
+ /**
+ * 是否计入八次自动尝试额度;人工技术重试只增加总尝试次数。
+ */
+ private final boolean autoAttempt;
+
+ /**
+ * 构造尝试触发来源协议项。
+ *
+ * @param type 数据库存储的稳定触发编码
+ * @param description 管理端展示名称
+ * @param autoAttempt 是否消耗八次自动尝试额度
+ */
+ HardAutoRefundTriggerTypeEnum(String type, String description, boolean autoAttempt) {
+ this.type = type;
+ this.description = description;
+ this.autoAttempt = autoAttempt;
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAutoRefundTaskPageDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAutoRefundTaskPageDto.java
new file mode 100644
index 000000000..a4594a3ab
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAutoRefundTaskPageDto.java
@@ -0,0 +1,57 @@
+package com.bsd.pjcloud.hard.api.dto.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 自动退款任务管理分页筛选条件。
+ */
+@Data
+@Schema(description = "自动退款任务分页筛选")
+public class HardAutoRefundTaskPageDto {
+
+ /**
+ * 精确任务主键,管理端通常不传。
+ */
+ @Schema(description = "任务主键")
+ private Long id;
+
+ /**
+ * 精确售后单 ID。
+ */
+ @Schema(description = "售后单ID")
+ private Long afterSaleId;
+
+ /**
+ * 精确原订单 ID。
+ */
+ @Schema(description = "原订单ID")
+ private Long orderId;
+
+ /**
+ * 精确商户 ID;必须同时受服务端数据范围约束。
+ */
+ @Schema(description = "商户ID")
+ private Long mchId;
+
+ /**
+ * 任务状态编码。
+ */
+ @Schema(description = "任务状态")
+ private String status;
+
+ /**
+ * 任务创建时间闭区间,数组长度应为 2。
+ */
+ @Schema(description = "创建时间范围")
+ private LocalDateTime[] createTimeList;
+
+ /**
+ * 当前管理用户可访问的商户 ID 列表,由 Controller 根据数据范围注入,不接受客户端放宽。
+ */
+ @Schema(hidden = true)
+ private List 一个分组对应一个父购物车和一个预约日期餐段,服务端会为每个分组下的每位实际用餐人
- * 创建独立订单,并统一从当前付款人选择的钱包扣款。
_6=*G)iuMxeTSukFUvIq{x(ALMg z3r~6z4zcY9)~?lizZnT_AmMl%fuVwk{SgDF9rEG5T20m83IJFFr{%LOlz6eB_N_whxA0vVy^R{EXM2i(|~W_(wYEn0J{X!I+_7-jT*MJQ1Uc8R@@`f(Y5uo zr71Up_4TsY>q#OO@y1ra)IIP?fGM6zjKS!o{pErs=_YQ20dlJQnt;Cx<&cGNcZ*VN zG?0;%_yO58W2=2tj0~47lG#8CJBYb2rG!#S+=#Z^h`wT20Jd;K!IDk-pb?w+>nLrn z$TC1mVxLA_sTn=P{~Kn^3)KVS8qLE^beOJrGGQsP9WWbEbI5TKwLql41iv@~i5d$4 zIs=I^zs$95WuVZmgEk}IM||BRpshrgVB5(CjMfY3uXn}|kpHj&>p0E)Bd6V)pw5J+ zEMjBCe&bYLx59J36JdznGXj6EyL+lFTEUjAZoAU)Hek0Jqdq|(FQg37 Q_|23*5uhp*9D$Q!6U4?^CVhX$1G~QbtM+;(G?)HAI zZ{3<^pjF^o0`@J#-`f5M`HS;>Yc h8Pu?FwyR?{rSJF-acJ}AL zy2)yIPgSa`sU|;neufPL;vCL9Yg&MCVug%co#qW#6tKo+#EM&2OIt}-NMYe(v;m`5 z#=F-d=#t94-)eZ3k=H4alkV<9s_UJqhwU$y#X%JH!3Me=Ztn%vwe1xtw`7;#+)_oV z*gsTSp)x7x>^Gu#!v*B!B9a=)XEFM)rqxCv80v~VUf}^ JA zda(C8F>P?}l52eg7kC8c`)X3{Nd-1PgjYF9=&ZLRgAGWq*E9vZ-Cq{y9QmbPLu#~I zr!@!kPN`ODlj|H EuA+*(y^F zy}w5maF!&MjAXJ1tP3uzS!=mEQahzOdayO5y^fnhZuFmyIN0?U!@Rj>N3lIVz~mqN zJV-JTEYkPI{2m{Whh}Z&uWb_nt81OOw5zVKBY?6Y8v;%U!7ywE*cvAJg{gv?@b_Ro zUiSW>xwUR6y>6vm>OlB&twEKSnP2YdJ5AZEbgI=BL^mLghnJV ig zcar|!P7d#VuIue3k|kl6d=7?wcREXd3%k2;ur=y6T5vCb!);c(HMn5pAEMlUT3FQo zfp{$e8-d}2F9WbJdzl)we}b4>0y6vxZYgj1gxw06?D?jYcO}o@+Vc}!=KKAVy%7sj zjt?IW-uA#pg9T@i!hK(4#QPI5QhO@N^pX=kX8sJ};_asgL51CJg6DkG-n-MQ|E|7m z|HQ&j-s$fR{+4lUzBiEcFeo$livQ&hy(^fyWucyg8(Nac1Yr}5$`vxM((YHhEj$U$ zAL@Um$Sulq7N!)Vi!8FvC!O=QOO_`{D54uOVj=_)-r7PV3gMC>F}5!#xCDR+wGs0u z!#p (_7-SPR(%9OpbG@c0QyiaRRB=+D`;B=IYqC?K>e=>)s|vXEMq&7r=%Cedf8n z-*HZw6KQuA5SPt~tt6WITyFx_(Xece*QXr(>JsZQs!xKvAFx>QWHa1+fr;CTNBaQO z!&}gpM?o*G((r4wZ^e#ka}(r+-QFK!VC}1dxi%7dF`1=Uz*-2!&ZZ6KZOwM;d%mt+ z*~mASS9;U4Mk5D*HGzSECYgPa!(YL<_pmsGpICO66`*KtKm5Q37fGc +PLcZ_YPlF;bW4ZNsY^^bqRznN##os~}jpr{dfGED; zqfnC`!%%&vxWcC7zaH*7w0gVguz7_epC56aY%(&u@Dpnb4Dl{g`uo9p6_@XM0l4+f zSHa!nm^Zwo>GF4RBC!%5LR@~v@VnoOd;qof6eJ36-8(B&MikoBXT$4jjp(tM4Mixh zDa#GI)_NA`A%`mpM>R+%?8c35z4|=z=l<0J%d1yif@_xAj-?d<`Ka}uwWkI#=J7n@ zSIO)=3TMvPp;LQmDD!AgR6$5sJ@qQK>yYTCx|GPNP x271h6lFW&A>0d;|bQA=LN!*Aof;b;q&9#+| O)#z-1f#oB0a~-~>VDCxihx(^z%g zU%O(WjM{!m`b(5z59CPj;DE f30Fyu>@{CySg~Q(_J|5-C-K!t9J_k{5jIQR~x9I8qA 3azX2^T2O;w)BPn$PJ6S@72m&1xMAln!ro$5Z~?PtE% z?)q`gaH g_}ug zrV6aGB*GKL>mhjbDS1wT_gfMBg2ir6dy4!l^&dkbIw$$8O7@RHsP4Jb-9Z5d0hhCH zbd=cY^oqLKM%}a$Z7$P^4Gnf4*!;$RoMkrrSEcmT%KWqKz=B|QQwa2L4iz}`|6W;~ IQ^HID069iBumAu6 literal 0 HcmV?d00001 diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserIdentityService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserIdentityService.java index 144da5760..ee2128f94 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserIdentityService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserIdentityService.java @@ -7,15 +7,23 @@ import com.bsd.pjcloud.admin.api.vo.UserVO; import java.util.List; +/** + * 用户身份及其学生、老师扩展资料的业务服务。 + */ public interface SysUserIdentityService extends IService { /** - * 校验并规范化用户身份请求参数 + * 校验并规范化用户身份请求参数。 + * + * @param userDto 新增或编辑用户时提交的完整用户信息 */ void validateDto(UserDTO userDto); /** - * 保存用户身份及对应扩展信息 + * 覆盖保存用户身份及对应扩展信息。 + * + * @param userId 已保存的系统用户 ID + * @param userDto 已完成身份参数校验的用户信息 */ void saveUserIdentity(Long userId, UserDTO userDto); @@ -27,7 +35,16 @@ public interface SysUserIdentityService extends IService void deleteUserIdentityByUserIds(List userIds); /** - * 填充用户身份及对应扩展信息 + * 填充单个用户的身份及对应扩展信息。 + * + * @param userVo 已查询到基础数据的用户展示对象 */ void fillUserIdentity(UserVO userVo); + + /** + * 批量填充用户身份及对应扩展信息,供分页列表避免逐用户查询。 + * + * @param userVos 已查询到基础数据的用户展示对象列表 + */ + void fillUserIdentities(List userVos); } 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 c43e6d18f..c951c08aa 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 @@ -321,7 +321,12 @@ public class SysUserServiceImpl extends ServiceImpl impl } /** - * 分页查询用户信息(含有角色信息) + * 分页查询用户信息,并批量补齐角色、人脸、身份及身份扩展资料。 + * + * @param page 分页参数,包含当前页和每页数量 + * @param userDTO 用户筛选条件及是否查询审批状态的控制参数 + * @param dataScope 当前调用人的数据权限范围;为空时不附加数据范围限制 + * @return 含角色、人脸、身份和审批信息的用户分页结果 */ @Override public IPage getUsersWithRolePage(Page page, UserDTO userDTO, DataScope dataScope) { @@ -331,6 +336,8 @@ public class SysUserServiceImpl extends ServiceImpl impl IPage list = baseMapper.getUserVosPage(page, userDTO, dataScope); if (ObjectUtil.isNotEmpty(list.getRecords())) { List userIds = list.getRecords().stream().map(UserVO::getUserId).distinct().toList(); + // 分页结果统一补齐身份及扩展资料,保证管理端列表与详情接口使用同一套身份语义。 + sysUserIdentityService.fillUserIdentities(list.getRecords()); if (ObjectUtil.isNotEmpty(userIds)) { SysUserFaceDto faceDto = new SysUserFaceDto(); List faceList = sysUserFaceMapper.groupConcatFaces(faceDto); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java index 217a715d7..434e176e9 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java @@ -24,10 +24,13 @@ import org.springframework.stereotype.Service; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; /** - * 用户身份表 + * 用户身份及其学生、老师扩展资料的业务服务实现。 */ @Service @RequiredArgsConstructor @@ -168,24 +171,58 @@ public class SysUserIdentityServiceImpl extends ServiceImpl identityTypes = list(Wrappers. lambdaQuery() - .eq(SysUserIdentityEntity::getUserId, userVo.getUserId())) - .stream() - .map(SysUserIdentityEntity::getIdentityType) - .toList(); - userVo.setIdentityTypes(identityTypes); + fillUserIdentities(List.of(userVo)); + } - if (identityTypes.contains(UserIdentityTypeEnum.STUDENT.getType())) { - SysUserStudentEntity student = sysUserStudentService.getById(userVo.getUserId()); - if (student != null) { + /** + * 分批查询身份、学生资料和老师资料,并按用户 ID 回填分页记录。 + * 固定执行至多三次批量查询,避免用户列表随分页条数产生 N+1 查询。
+ * + * @param userVos 已查询到基础数据的用户展示对象列表;空列表或无有效用户 ID 时不执行查询 + */ + @Override + public void fillUserIdentities(ListuserVos) { + if (CollUtil.isEmpty(userVos)) { + return; + } + List userIds = userVos.stream() + .filter(userVo -> userVo != null && userVo.getUserId() != null) + .map(UserVO::getUserId) + .distinct() + .toList(); + if (CollUtil.isEmpty(userIds)) { + return; + } + + Map > identityTypesByUserId = list(Wrappers. lambdaQuery() + .in(SysUserIdentityEntity::getUserId, userIds) + .orderByAsc(SysUserIdentityEntity::getCreateTime)) + .stream() + .collect(Collectors.groupingBy(SysUserIdentityEntity::getUserId, + Collectors.mapping(SysUserIdentityEntity::getIdentityType, Collectors.toList()))); + Map studentByUserId = sysUserStudentService.listByIds(userIds).stream() + .collect(Collectors.toMap(SysUserStudentEntity::getUserId, Function.identity(), (first, ignored) -> first)); + Map teacherByUserId = sysUserTeacherService.listByIds(userIds).stream() + .collect(Collectors.toMap(SysUserTeacherEntity::getUserId, Function.identity(), (first, ignored) -> first)); + + for (UserVO userVo : userVos) { + if (userVo == null || userVo.getUserId() == null) { + continue; + } + List identityTypes = identityTypesByUserId.getOrDefault(userVo.getUserId(), List.of()); + userVo.setIdentityTypes(identityTypes); + userVo.setStudentInfo(null); + userVo.setTeacherInfo(null); + + SysUserStudentEntity student = studentByUserId.get(userVo.getUserId()); + if (identityTypes.contains(UserIdentityTypeEnum.STUDENT.getType()) && student != null) { SysUserStudentDto studentInfo = new SysUserStudentDto(); BeanUtils.copyProperties(student, studentInfo); userVo.setStudentInfo(studentInfo); } - } - if (identityTypes.contains(UserIdentityTypeEnum.TEACHER.getType())) { - SysUserTeacherEntity teacher = sysUserTeacherService.getById(userVo.getUserId()); - if (teacher != null) { + + SysUserTeacherEntity teacher = teacherByUserId.get(userVo.getUserId()); + if (identityTypes.contains(UserIdentityTypeEnum.TEACHER.getType()) && teacher != null) { SysUserTeacherDto teacherInfo = new SysUserTeacherDto(); BeanUtils.copyProperties(teacher, teacherInfo); userVo.setTeacherInfo(teacherInfo); diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/mch/MchAutoRefundRuleTypeEnum.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/mch/MchAutoRefundRuleTypeEnum.java new file mode 100644 index 000000000..00a2fccce --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/mch/MchAutoRefundRuleTypeEnum.java @@ -0,0 +1,49 @@ +package com.bsd.pjcloud.wallet.api.constant.mch; + +import lombok.Getter; + +import java.util.Arrays; + +/** + * 商户自动退款截止规则。枚举名称会写入配置表和订单快照,属于跨服务持久化协议,禁止随意改名。 + */ +@Getter +public enum MchAutoRefundRuleTypeEnum { + + /** + * 以订单支付成功时间为起点,按配置小时数计算免审申请截止时间。 + */ + PAY_TIME_LIMIT("支付后限时"), + + /** + * 使用下单时已经校验通过的周期预约批次截止时间作为免审申请截止时间。 + */ + APPT_CYCLE_CLOSE("周期预约截止"); + + /** + * 管理端展示名称,不参与数据库持久化和跨服务判断。 + */ + private final String description; + + /** + * 构造自动退款截止规则。 + * + * @param description 管理端展示名称;持久化和值判断始终使用枚举名称 + */ + MchAutoRefundRuleTypeEnum(String description) { + this.description = description; + } + + /** + * 按配置表持久化名称解析规则,未知值返回 {@code null},由调用方降级为人工审核。 + * + * @param value 配置表中的规则名称 + * @return 匹配的规则;值为空或未知时返回 {@code null} + */ + public static MchAutoRefundRuleTypeEnum valueOfName(String value) { + if (value == null || value.isBlank()) { + return null; + } + return Arrays.stream(values()).filter(item -> item.name().equals(value)).findFirst().orElse(null); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigBatchQueryDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigBatchQueryDto.java new file mode 100644 index 000000000..03fc5b10c --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigBatchQueryDto.java @@ -0,0 +1,30 @@ +package com.bsd.pjcloud.wallet.api.dto.mch; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +/** + * 跨服务批量查询商户自动退款配置的请求,用于批量任务恢复时避免逐商户远程调用。 + */ +@Data +@Schema(description = "商户自动退款配置批量查询请求") +public class MchAutoRefundConfigBatchQueryDto { + + /** + * 商户 ID 列表;服务端会去空、去重并按当前租户查询,不执行数据范围放宽。 + */ + @NotEmpty(message = "商户列表不能为空") + @Schema(description = "商户ID列表") + private List mchIds; + + /** + * 硬件订单类型编码;首期固定为预约订单编码 {@code 2}。 + */ + @NotBlank(message = "订单类型不能为空") + @Schema(description = "硬件订单类型编码") + private String orderType; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigDto.java new file mode 100644 index 000000000..cc1c5d82f --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigDto.java @@ -0,0 +1,54 @@ +package com.bsd.pjcloud.wallet.api.dto.mch; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 管理端新增或修改商户自动退款配置的请求。 + */ +@Data +@Schema(description = "商户自动退款配置请求") +public class MchAutoRefundConfigDto { + + /** + * 已有配置主键;首次保存允许为空,后续更新由服务端按商户和订单类型定位原记录。 + */ + @Schema(description = "配置主键") + private Long id; + + /** + * 待配置商户 ID,必须处于当前管理用户的数据范围内。 + */ + @NotNull(message = "商户不能为空") + @Schema(description = "商户ID") + private Long mchId; + + /** + * 硬件订单类型编码,首期只接受预约订单编码 {@code 2}。 + */ + @NotBlank(message = "订单类型不能为空") + @Schema(description = "硬件订单类型编码") + private String orderType; + + /** + * 自动退款开关,只接受 {@code 1} 或 {@code 0}。 + */ + @NotBlank(message = "自动退款开关不能为空") + @Schema(description = "是否开启自动退款") + private String enabled; + + /** + * 截止规则名称,只接受首期定义的支付限时或周期截止规则。 + */ + @NotBlank(message = "自动退款规则不能为空") + @Schema(description = "自动退款截止规则") + private String ruleType; + + /** + * 支付后限时小时数;为空时按 24 小时保存,有效范围为 1 至 168,周期规则会被服务端清空。 + */ + @Schema(description = "支付后可自动退款小时数") + private Integer payTimeLimitHours; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeRefundDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeRefundDto.java index 7ab691b62..3d064d936 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeRefundDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeRefundDto.java @@ -9,29 +9,55 @@ import lombok.Data; import lombok.NoArgsConstructor; import org.hibernate.validator.constraints.Length; +/** + * 钱包退款请求。售后单 ID 同时作为钱包入账幂等键,自动退款时额外携带配置主键执行最终开关校验。 + */ @Builder @AllArgsConstructor @NoArgsConstructor @Data public class WalletRechargeRefundDto { + /** + * 退款到账的钱包 ID,重复请求必须与首笔退款记录保持一致。 + */ @NotNull(message = "钱包id不能为空") private Long walletId; + /** + * Hardware 原订单 ID,用于跨服务问题定位,不作为钱包幂等键。 + */ @NotNull(message = "订单编号不能为空") private Long orderId; + /** + * Hardware 售后单 ID,是 {@code wallet_recharge.pay_refund_order_id} 的唯一幂等业务号。 + */ @NotNull(message = "售后订单id不能为空") private Long afterSaleId; + /** + * 退款业务编码,自动退款固定使用 {@code hard_{orderId}_{afterSaleId}}。 + */ @NotEmpty(message = "退款code不能为空") private String refundCode; + /** + * 钱包退款流水说明,最长 128 个字符。 + */ @NotEmpty(message = "退款描述不能为空") @Length(max = 128, message = "退款描述长度不能超过 128") private String reason; + /** + * 本次退款金额,单位为分,必须大于零。 + */ @NotNull(message = "退款金额不能为空") @Min(value = 1, message = "退款金额必须大于零") private Integer price; + + /** + * 自动退款配置主键;人工审核退款为空,非空时 Wallet 必须锁定配置并确认开关仍开启。 + */ + private Long autoRefundConfigId; } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchAutoRefundConfigEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchAutoRefundConfigEntity.java new file mode 100644 index 000000000..88f0f4f00 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchAutoRefundConfigEntity.java @@ -0,0 +1,111 @@ +package com.bsd.pjcloud.wallet.api.entity.mch; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 商户自动退款配置。每个租户、商户和订单类型只允许存在一条未删除配置。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@TenantTable +@TableName("mch_auto_refund_config") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "商户自动退款配置") +public class MchAutoRefundConfigEntity extends Model { + + /** + * 配置主键,使用 MyBatis-Plus 雪花 ID;订单快照和自动退款任务均引用该值。 + */ + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "配置主键") + private Long id; + + /** + * 独立配置的商户 ID,父店和分店互不继承。 + */ + @Schema(description = "商户ID") + private Long mchId; + + /** + * 可自动退款的硬件订单类型编码;首期只允许预约订单编码 {@code 2}。 + */ + @Schema(description = "硬件订单类型编码") + private String orderType; + + /** + * 自动退款开关,持久化值为 {@code 1} 开启、{@code 0} 关闭。 + */ + @Schema(description = "是否开启自动退款") + private String enabled; + + /** + * 截止规则名称,对应 {@code MchAutoRefundRuleTypeEnum.name()} 持久化协议。 + */ + @Schema(description = "自动退款截止规则") + private String ruleType; + + /** + * 支付时间规则的有效小时数,允许 1 至 168;周期规则下为空。 + */ + @Schema(description = "支付后可自动退款小时数") + private Integer payTimeLimitHours; + + /** + * 创建人账号,由公共字段填充器在新增时写入。 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建人") + private String createBy; + + /** + * 配置创建时间,使用数据库连接和服务器默认时区。 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + /** + * 最后修改人账号,由公共字段填充器维护。 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改人") + private String updateBy; + + /** + * 最后修改时间,使用数据库连接和服务器默认时区。 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改时间") + private LocalDateTime updateTime; + + /** + * 逻辑删除标记;本功能不提供删除接口,字段仅遵循平台通用表规范。 + */ + @TableLogic + @TableField(fill = FieldFill.INSERT) + @Schema(description = "逻辑删除标记") + private String delFlag; + + /** + * 租户 ID,由多租户拦截器参与查询隔离,不能为空。 + */ + @Schema(description = "租户ID") + private Long tenantId; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteMchAutoRefundConfigService.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteMchAutoRefundConfigService.java new file mode 100644 index 000000000..dcd8f12a6 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteMchAutoRefundConfigService.java @@ -0,0 +1,42 @@ +package com.bsd.pjcloud.wallet.api.feign; + +import com.bsd.pjcloud.common.core.constant.ServiceNameConstants; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.feign.annotation.NoToken; +import com.bsd.pjcloud.wallet.api.dto.mch.MchAutoRefundConfigBatchQueryDto; +import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +/** + * Wallet 商户自动退款配置的独立跨服务契约。 + */ +@FeignClient(contextId = "remoteMchAutoRefundConfigService", value = ServiceNameConstants.WALLET_SERVICE) +public interface RemoteMchAutoRefundConfigService { + + /** + * 按商户和订单类型精确查询当前租户配置,返回关闭配置以便 Hardware 区分暂停和未配置。 + * + * @param mchId 商户 ID + * @param orderType 硬件订单类型编码 + * @return 匹配配置;未配置时响应数据为空 + */ + @NoToken + @GetMapping("/mch-auto-refund-config/inner/exact") + R getExact(@RequestParam Long mchId, @RequestParam String orderType); + + /** + * 批量查询同一订单类型下的商户配置,结果不包含未配置商户。 + * + * @param dto 商户列表和订单类型 + * @return 当前租户下匹配的配置列表 + */ + @NoToken + @PostMapping("/mch-auto-refund-config/inner/batch") + R > listBatch(@RequestBody MchAutoRefundConfigBatchQueryDto dto); +} 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 3cd62fa09..5b181214e 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 @@ -12,6 +12,9 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +/** + * Wallet 充值与退款的跨服务契约,供支付和 Hardware 服务读取可信资金记录。 + */ @FeignClient(contextId = "remoteWalletRechargeService", value = ServiceNameConstants.WALLET_SERVICE) public interface RemoteWalletRechargeService { @@ -26,13 +29,23 @@ public interface RemoteWalletRechargeService { R
getRechargeById(@PathVariable("rechargeId") Long rechargeId); /** - * 退款 + * 按售后单执行钱包退款,重复请求由 Wallet 校验资金目标和金额后返回原记录。 * - * @param dto - * @return + * @param dto Hardware 退款请求 + * @return 新建或已存在的钱包退款记录 */ @NoToken @PostMapping("/wallet-recharge/refund") R refund(@RequestBody WalletRechargeRefundDto dto); + /** + * 按售后单 ID 查询已经持久化的退款记录,用于 Wallet 已到账但 Hardware 本地事务回滚后的恢复判断。 + * + * @param afterSaleId Hardware 售后单 ID + * @return 已存在退款记录;尚未退款时响应数据为空 + */ + @NoToken + @GetMapping("/wallet-recharge/remote/refund/{afterSaleId}") + R getRefundByAfterSaleId(@PathVariable("afterSaleId") Long afterSaleId); + } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletService.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletService.java index b861c225f..28219d799 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletService.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletService.java @@ -29,6 +29,18 @@ public interface RemoteWalletService { @GetMapping("/wallet-manage/get-wallet") R getWallet(@RequestParam Long userId, @RequestParam WalletTypeEnum walletTypeCode); + /** + * 按用户列表和钱包业务类型批量查询钱包,供跨服务批量结算使用。 + * + * @param userIds 目标用户 ID 列表 + * @param walletTypeCode 钱包业务类型;多人订餐固定传 {@link WalletTypeEnum#EAT} + * @return 已存在的钱包列表;未开通钱包的用户不会出现在结果中 + */ + @NoToken + @PostMapping("/wallet-manage/get-wallets-by-user-ids") + R > getWalletsByUserIds(@RequestBody List
userIds, + @RequestParam WalletTypeEnum walletTypeCode); + @NoToken @GetMapping("/wallet-manage/get-wallet-by-wallet-id") R getWalletByWalletTypeId(@RequestParam Long userId, @RequestParam Long walletTypeId); diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletTransactionService.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletTransactionService.java index f92a49758..fd3ecea7d 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletTransactionService.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletTransactionService.java @@ -31,6 +31,17 @@ public interface RemoteWalletTransactionService { @PostMapping("/wallet-transaction/reduce-balance-idempotent") R reduceWalletBalanceIdempotent(@RequestBody WalletBalanceTransactionDto dto); + /** + * 在钱包服务单个事务内批量校验余额并按订单业务号幂等扣款。 + * + * @param dtoList 每笔订单对应的钱包扣款明细 + * @return 与请求明细一一对应的钱包流水 + */ + @NoToken + @PostMapping("/wallet-transaction/reduce-balance-batch-idempotent") + R > reduceWalletBalanceBatchIdempotent( + @RequestBody List
dtoList); + /** * 仅在对应支付流水存在时执行幂等补偿退款。 */ @@ -38,6 +49,16 @@ public interface RemoteWalletTransactionService { @PostMapping("/wallet-transaction/compensate-payment") R compensatePayment(@RequestBody WalletBalanceTransactionDto dto); + /** + * 在钱包服务单个事务内批量补偿已存在的支付流水,重复调用不会重复入账。 + * + * @param dtoList 待核对并补偿的订单支付明细 + * @return {@code true} 表示全部明细均已处于未扣款或已补偿状态 + */ + @NoToken + @PostMapping("/wallet-transaction/compensate-payment-batch") + R compensatePaymentBatch(@RequestBody List dtoList); + @NoToken @PostMapping("/wallet-transaction/sum-and-count") R > sumAndCount(@RequestBody WalletTransactionSumAndCountDto dto); diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchAutoRefundConfigVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchAutoRefundConfigVo.java new file mode 100644 index 000000000..30e215127 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchAutoRefundConfigVo.java @@ -0,0 +1,15 @@ +package com.bsd.pjcloud.wallet.api.vo.mch; + +import com.bsd.pjcloud.wallet.api.entity.mch.MchAutoRefundConfigEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 商户自动退款配置响应,保留实体全部配置和审计字段供管理端与 Hardware 服务读取。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "商户自动退款配置响应") +public class MchAutoRefundConfigVo extends MchAutoRefundConfigEntity { +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/app/GuardianWardRechargeItemVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/app/GuardianWardRechargeItemVo.java index 7273b8bee..1993cba52 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/app/GuardianWardRechargeItemVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/app/GuardianWardRechargeItemVo.java @@ -33,6 +33,11 @@ public class GuardianWardRechargeItemVo { */ private String wardAvatar; + /** + * 用户是否已经开通 EAT 饭卡。该字段用于区分未开户与已开户但余额为零,旧客户端可安全忽略。 + */ + private Boolean walletOpened; + /** * 饭卡类型名称。 */ diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/resources/META-INF/spring/org.springframework.cloud.openfeign.FeignClient.imports b/pjcloud-wallet/pjcloud-wallet-api/src/main/resources/META-INF/spring/org.springframework.cloud.openfeign.FeignClient.imports index 05a2c4b1b..6d3ee5651 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/resources/META-INF/spring/org.springframework.cloud.openfeign.FeignClient.imports +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/resources/META-INF/spring/org.springframework.cloud.openfeign.FeignClient.imports @@ -4,6 +4,7 @@ com.bsd.pjcloud.wallet.api.feign.RemoteComActService com.bsd.pjcloud.wallet.api.feign.RemoteIntegralTransactionService com.bsd.pjcloud.wallet.api.feign.RemoteMchPickUpService com.bsd.pjcloud.wallet.api.feign.RemoteMchRuleJoinService +com.bsd.pjcloud.wallet.api.feign.RemoteMchAutoRefundConfigService com.bsd.pjcloud.wallet.api.feign.RemoteCodeUseService com.bsd.pjcloud.wallet.api.feign.RemoteWalletRechargeService com.bsd.pjcloud.wallet.api.feign.RemoteWalletRuleTransactionService diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java index 16cbd557b..7f9e550c8 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java @@ -101,6 +101,20 @@ public class WalletManageController { return R.ok(wallet); } + /** + * 批量查询指定用户已经开通的钱包;缺失用户不报错,由结算服务生成带姓名的业务提示。 + * + * @param userIds 目标用户 ID 列表 + * @param walletTypeCode 钱包业务类型 + * @return 已存在的钱包列表 + */ + @Inner + @PostMapping("/get-wallets-by-user-ids") + public R
> getWalletsByUserIds(@RequestBody List
userIds, + @RequestParam WalletTypeEnum walletTypeCode) { + return R.ok(walletManageService.getWalletsByUserIds(userIds, walletTypeCode)); + } + @Inner @GetMapping("/get-wallet-by-id") public R getWalletById(@RequestParam Long walletId) { diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java index e69a7a857..4cfe639cf 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java @@ -78,6 +78,19 @@ public class WalletTransactionController { return R.ok(walletTransactionService.reduceWalletBalanceIdempotent(dto.getWalletId(), dto.getPrice(), dto.getBizId(), dto.getBizType(), dto.getForce())); } + /** + * 整批钱包扣款在一个钱包服务事务内完成,任一钱包余额不足时不保留部分扣款。 + * + * @param dtoList 每笔订单的钱包支付明细 + * @return 与请求顺序一致的钱包流水 + */ + @Inner + @PostMapping("/reduce-balance-batch-idempotent") + public R > reduceWalletBalanceBatchIdempotent( + @Valid @RequestBody List
dtoList) { + return R.ok(walletTransactionService.reduceWalletBalanceBatchIdempotent(dtoList)); + } + /** * 仅补偿已经存在的支付流水,未实际扣款或重复补偿均按成功处理。 */ @@ -87,6 +100,18 @@ public class WalletTransactionController { return R.ok(walletTransactionService.compensatePayment(dto.getWalletId(), dto.getPrice(), dto.getBizId())); } + /** + * 批量补偿只退回实际存在的支付流水,适用于调用方远程结果未知或本地事务回滚场景。 + * + * @param dtoList 待核对并补偿的订单支付明细 + * @return {@code true} 表示整批补偿完成 + */ + @Inner + @PostMapping("/compensate-payment-batch") + public R compensatePaymentBatch(@Valid @RequestBody List dtoList) { + return R.ok(walletTransactionService.compensatePaymentBatch(dtoList)); + } + @Inner @PostMapping("/sum-and-count") public R > sumAndCount(@RequestBody WalletTransactionSumAndCountDto dto) { diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchAutoRefundConfigController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchAutoRefundConfigController.java new file mode 100644 index 000000000..7f4a907e7 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchAutoRefundConfigController.java @@ -0,0 +1,96 @@ +package com.bsd.pjcloud.wallet.controller.mch; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.log.annotation.SysLog; +import com.bsd.pjcloud.common.security.annotation.Inner; +import com.bsd.pjcloud.wallet.api.dto.mch.MchAutoRefundConfigBatchQueryDto; +import com.bsd.pjcloud.wallet.api.dto.mch.MchAutoRefundConfigDto; +import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo; +import com.bsd.pjcloud.wallet.service.MchAutoRefundConfigService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.access.prepost.PreAuthorize; +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; + +/** + * 商户自动退款配置接口。管理端只提供查询和单行保存,不提供删除能力。 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/mch-auto-refund-config") +@Tag(description = "mch-auto-refund-config", name = "商户自动退款配置") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class MchAutoRefundConfigController { + + /** + * 商户自动退款配置服务,统一执行数据范围和配置协议校验。 + */ + private final MchAutoRefundConfigService configService; + + /** + * 查询当前管理用户可访问商户的指定订单类型配置。 + * + * @param mchId 商户 ID + * @param orderType 硬件订单类型编码 + * @return 匹配配置;未配置时数据为空 + */ + @GetMapping("/mch/{mchId}") + @Operation(summary = "按商户查询自动退款配置") + @PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')") + public R
getForManagement(@PathVariable Long mchId, @RequestParam String orderType) { + return R.ok(configService.getForManagement(mchId, orderType)); + } + + /** + * 新增或修改商户唯一自动退款配置,关闭操作也在同一配置行锁内提交。 + * + * @param dto 商户自动退款配置 + * @return 保存后的配置 + */ + @PostMapping + @Operation(summary = "新增或修改自动退款配置") + @SysLog("新增或修改商户自动退款配置") + @PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')") + public R saveOrUpdate(@Valid @RequestBody MchAutoRefundConfigDto dto) { + return R.ok(configService.saveOrUpdateConfig(dto)); + } + + /** + * Hardware 服务按商户和订单类型查询当前租户配置。 + * + * @param mchId 商户 ID + * @param orderType 硬件订单类型编码 + * @return 匹配配置;未配置时数据为空 + */ + @Inner + @GetMapping("/inner/exact") + @Operation(summary = "内部精确查询自动退款配置") + public R getExact(@RequestParam Long mchId, @RequestParam String orderType) { + return R.ok(configService.getExact(mchId, orderType)); + } + + /** + * Hardware 服务批量查询当前租户同一订单类型的配置。 + * + * @param dto 商户列表和订单类型 + * @return 匹配配置列表 + */ + @Inner + @PostMapping("/inner/batch") + @Operation(summary = "内部批量查询自动退款配置") + public R > listBatch(@Valid @RequestBody MchAutoRefundConfigBatchQueryDto dto) { + return R.ok(configService.listBatch(dto)); + } +} 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 2e6654d14..2e5d61116 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 @@ -48,6 +48,9 @@ import java.util.List; @SecurityRequirement(name = HttpHeaders.AUTHORIZATION) public class WalletRechargeController { + /** + * 钱包充值服务,统一处理充值查询、退款幂等和关系人充值业务。 + */ private final WalletRechargeService walletRechargeService; /* ************************************* start 远程调用 start ************************************* */ @@ -57,6 +60,19 @@ public class WalletRechargeController { public R
refund(@Valid @RequestBody WalletRechargeRefundDto dto) { return R.ok(walletRechargeService.refund(dto)); } + + /** + * 查询售后单是否已经形成钱包退款记录,仅用于跨服务一致性恢复,不提供管理端修改能力。 + * + * @param afterSaleId Hardware 售后单 ID + * @return 已存在退款记录;尚未退款时数据为空 + */ + @Inner + @Operation(summary = "远程调用-按售后单查询退款记录") + @GetMapping("/remote/refund/{afterSaleId}") + public R getRefundByAfterSaleId(@PathVariable Long afterSaleId) { + return R.ok(walletRechargeService.getByRefundId(afterSaleId)); + } /* ************************************* end 远程调用 end ************************************* */ /* ************************************* start app start ************************************* */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java index 6cbc8e275..a3ba4a218 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java @@ -74,7 +74,10 @@ public interface WalletManageMapper extends PjcloudBaseMapper () diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchAutoRefundConfigMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchAutoRefundConfigMapper.java new file mode 100644 index 000000000..fe38f9263 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchAutoRefundConfigMapper.java @@ -0,0 +1,39 @@ +package com.bsd.pjcloud.wallet.mapper.mch; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.wallet.api.entity.mch.MchAutoRefundConfigEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 商户自动退款配置 Mapper,提供退款终审和配置修改共用的数据库行锁。 + */ +@Mapper +public interface MchAutoRefundConfigMapper extends PjcloudBaseMapper { + + /** + * 按主键锁定配置行,保证开关修改和自动退款最终校验严格串行。 + * + * @param id 配置主键 + * @return 已锁定配置;不存在或已逻辑删除时返回 {@code null} + */ + default MchAutoRefundConfigEntity selectByIdForUpdate(Long id) { + return selectOne(new LambdaQueryWrapper () + .eq(MchAutoRefundConfigEntity::getId, id) + .last("FOR UPDATE")); + } + + /** + * 按商户和订单类型锁定唯一配置,供单行新增或修改复用原记录。 + * + * @param mchId 商户 ID + * @param orderType 硬件订单类型编码 + * @return 已锁定配置;尚未配置时返回 {@code null} + */ + default MchAutoRefundConfigEntity selectExactForUpdate(Long mchId, String orderType) { + return selectOne(new LambdaQueryWrapper () + .eq(MchAutoRefundConfigEntity::getMchId, mchId) + .eq(MchAutoRefundConfigEntity::getOrderType, orderType) + .last("FOR UPDATE")); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchAutoRefundConfigService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchAutoRefundConfigService.java new file mode 100644 index 000000000..fcc6a22ce --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchAutoRefundConfigService.java @@ -0,0 +1,65 @@ +package com.bsd.pjcloud.wallet.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.bsd.pjcloud.wallet.api.dto.mch.MchAutoRefundConfigBatchQueryDto; +import com.bsd.pjcloud.wallet.api.dto.mch.MchAutoRefundConfigDto; +import com.bsd.pjcloud.wallet.api.entity.mch.MchAutoRefundConfigEntity; +import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo; + +import java.util.List; + +/** + * 商户自动退款配置服务,统一承担管理端数据范围校验和退款终审行锁。 + */ +public interface MchAutoRefundConfigService extends IService { + + /** + * 在当前管理用户数据范围内查询指定商户配置。 + * + * @param mchId 商户 ID + * @param orderType 硬件订单类型编码 + * @return 匹配配置;未配置时返回 {@code null} + */ + MchAutoRefundConfigVo getForManagement(Long mchId, String orderType); + + /** + * 新增配置或修改商户现有唯一配置,不创建同商户同订单类型的第二条记录。 + * + * @param dto 管理端配置请求 + * @return 保存后的完整配置 + */ + MchAutoRefundConfigVo saveOrUpdateConfig(MchAutoRefundConfigDto dto); + + /** + * 跨服务精确查询当前租户配置,不应用登录用户数据范围。 + * + * @param mchId 商户 ID + * @param orderType 硬件订单类型编码 + * @return 匹配配置;未配置时返回 {@code null} + */ + MchAutoRefundConfigVo getExact(Long mchId, String orderType); + + /** + * 跨服务批量查询当前租户配置,不返回未配置商户。 + * + * @param dto 商户列表和订单类型 + * @return 匹配配置列表 + */ + List listBatch(MchAutoRefundConfigBatchQueryDto dto); + + /** + * 在当前事务中仅锁定自动退款配置,不提前判断开关;钱包需先用该锁串行配置修改和幂等记录检查。 + * + * @param configId 订单快照和任务引用的配置主键 + * @return 已锁定配置 + */ + MchAutoRefundConfigEntity lockForUpdate(Long configId); + + /** + * 在当前事务中锁定并校验自动退款配置仍然开启且规则有效。 + * + * @param configId 订单快照和任务引用的配置主键 + * @return 已锁定且可用的配置 + */ + MchAutoRefundConfigEntity requireEnabledForUpdate(Long configId); +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java index d842716d1..fa4830bba 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java @@ -39,6 +39,15 @@ public interface WalletManageService extends IService { */ WalletManageEntity getWallet(@NotNull Long userId, @NotNull WalletTypeEnum walletTypeCode); + /** + * 按钱包业务类型批量查询指定用户已经存在的钱包,不为缺失用户自动开户。 + * + * @param userIds 目标用户 ID 列表,空列表返回空结果 + * @param walletTypeCode 钱包业务类型 + * @return 已存在的钱包列表,调用方需自行判断缺失用户 + */ + List getWalletsByUserIds(List userIds, @NotNull WalletTypeEnum walletTypeCode); + /** * 获取钱包(没有缓存) */ @@ -135,4 +144,4 @@ public interface WalletManageService extends IService { * 编辑外卖配置 */ MchTakeOutSettingEntity editTakeOut(MchTakeOutSettingEntity dto); -} \ No newline at end of file +} 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 96755eef9..db5495010 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 @@ -73,18 +73,18 @@ public interface WalletRechargeService extends IService { GuardianWardRechargeResultVo getGuardianWardResult(Long guardianUserId, Long rechargeId); /** - * 远程调用-退款 + * 按售后单 ID 幂等执行钱包退款,自动请求还会在资金入账前锁定并终审商户配置。 * - * @param dto - * @return + * @param dto Hardware 退款请求 + * @return 新建或已存在的钱包退款记录 */ WalletRechargeVo refund(WalletRechargeRefundDto dto); - /** - * 根据退款id查询 + * 按 Hardware 售后单 ID 查询钱包退款记录。 * - * @return + * @param refundId 售后单 ID,也是钱包退款唯一幂等键 + * @return 已存在退款记录;尚未退款时返回 {@code null} */ WalletRechargeEntity getByRefundId(Long refundId); } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java index 7d3eeb85b..9d1f0b5dd 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.IService; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum; +import com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto; import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto; import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity; import com.bsd.pjcloud.wallet.api.vo.WalletCzTotalVo; @@ -52,11 +53,27 @@ public interface WalletTransactionService extends IService reduceWalletBalanceBatchIdempotent(List dtoList); + /** * 对指定支付业务执行幂等补偿;支付流水不存在时不增加余额。 */ Boolean compensatePayment(Long walletId, Integer price, String bizId); + /** + * 在同一本地事务内批量补偿已存在的支付流水,未扣款和已补偿明细均按成功处理。 + * + * @param dtoList 待核对并补偿的订单支付明细 + * @return {@code true} 表示整批均已处于未扣款或已补偿状态 + */ + Boolean compensatePaymentBatch(List dtoList); + /** * 增加钱包余额 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 9bba80d53..482e9a9c8 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 @@ -169,6 +169,31 @@ public class WalletManageServiceImpl extends ServiceImpl getWalletsByUserIds(List userIds, WalletTypeEnum walletTypeCode) { + if (ObjectUtil.isEmpty(userIds)) { + return List.of(); + } + WalletTypeEntity walletType = walletTypeService.getByUniqueCode(walletTypeCode); + if (ObjectUtil.isEmpty(walletType)) { + return List.of(); + } + List distinctUserIds = userIds.stream().filter(Objects::nonNull).distinct().toList(); + if (distinctUserIds.isEmpty()) { + return List.of(); + } + return baseMapper.selectList(new LambdaQueryWrapper () + .in(WalletManageEntity::getUserId, distinctUserIds) + .eq(WalletManageEntity::getWalletTypeId, walletType.getId())); + } + @Override public WalletManageEntity getWallet(String cardNumber) { return baseMapper.selectOne(new LambdaQueryWrapper () @@ -662,4 +687,4 @@ public class WalletManageServiceImpl extends ServiceImpl reduceWalletBalanceBatchIdempotent(List dtoList) { + List payments = validatePaymentBatch(dtoList); + acquirePaymentOperationLocks(payments, new ArrayList<>()); + if (payments.stream().anyMatch(this::isPaymentCancelled)) { + throw new RuntimeException("订单支付已经取消,不能继续扣款"); + } + Map lockedWalletMap = lockBatchWallets(payments); + Map pendingPriceMap = new LinkedHashMap<>(); + Map existingPaymentMap = new HashMap<>(); + + for (WalletBalanceTransactionDto payment : payments) { + WalletTransactionEntity compensated = findBizTransaction(payment.getWalletId(), payment.getBizId(), WalletBizEnum.REFUND); + if (compensated != null) { + throw new RuntimeException("订单支付已经补偿,不能重复扣款"); + } + WalletTransactionEntity existing = findBizTransaction(payment.getWalletId(), payment.getBizId(), WalletBizEnum.PAYMENT); + if (existing != null) { + if (!ObjectUtil.equals(existing.getPrice(), -payment.getPrice())) { + throw new RuntimeException("重复业务扣款金额不一致"); + } + existingPaymentMap.put(paymentBatchKey(payment), existing); + continue; + } + pendingPriceMap.merge(payment.getWalletId(), payment.getPrice(), Math::addExact); + } + + pendingPriceMap.forEach((walletId, totalPrice) -> { + WalletManageEntity wallet = lockedWalletMap.get(walletId); + if (wallet.getBalance() == null || wallet.getBalance() < totalPrice) { + throw new RuntimeException("钱包余额不足"); + } + }); + pendingPriceMap.forEach((walletId, totalPrice) -> { + if (walletManageMapper.updateWhenConsumption(walletId, totalPrice, false) != 1) { + throw new RuntimeException("钱包余额不足"); + } + }); + + Map runningBalanceMap = new HashMap<>(); + lockedWalletMap.forEach((walletId, wallet) -> runningBalanceMap.put(walletId, wallet.getBalance())); + List results = new ArrayList<>(payments.size()); + for (WalletBalanceTransactionDto payment : payments) { + WalletTransactionEntity existing = existingPaymentMap.get(paymentBatchKey(payment)); + if (existing != null) { + results.add(existing); + continue; + } + WalletManageEntity wallet = lockedWalletMap.get(payment.getWalletId()); + int afterBalance = Math.subtractExact(runningBalanceMap.get(payment.getWalletId()), payment.getPrice()); + runningBalanceMap.put(payment.getWalletId(), afterBalance); + WalletTransactionEntity transaction = buildWalletTransaction(wallet, payment.getWalletId(), + -payment.getPrice(), afterBalance, payment.getBizId(), WalletBizEnum.PAYMENT); + transaction.setTags(payment.getTags()); + if (!save(transaction)) { + throw new RuntimeException("保存钱包支付流水失败"); + } + results.add(transaction); + } + return results; + } + /** * 补偿前同时核对支付流水和退款流水,保证未知远程结果也可以安全地对整批订单逐笔补偿。 */ @@ -186,6 +298,247 @@ public class WalletTransactionServiceImpl extends ServiceImpl dtoList) { + List payments = validatePaymentBatch(dtoList); + List rollbackMarkerKeys = new ArrayList<>(); + acquirePaymentOperationLocks(payments, rollbackMarkerKeys); + Map lockedWalletMap = lockBatchWallets(payments); + Map refundPriceMap = new LinkedHashMap<>(); + Set needRefundKeys = new HashSet<>(); + + for (WalletBalanceTransactionDto payment : payments) { + WalletTransactionEntity existingRefund = findBizTransaction(payment.getWalletId(), payment.getBizId(), WalletBizEnum.REFUND); + if (existingRefund != null) { + if (!ObjectUtil.equals(existingRefund.getPrice(), payment.getPrice())) { + throw new RuntimeException("重复业务退款金额不一致"); + } + continue; + } + WalletTransactionEntity existingPayment = findBizTransaction(payment.getWalletId(), payment.getBizId(), WalletBizEnum.PAYMENT); + if (existingPayment == null) { + continue; + } + if (!ObjectUtil.equals(existingPayment.getPrice(), -payment.getPrice())) { + throw new RuntimeException("支付流水金额与补偿金额不一致"); + } + needRefundKeys.add(paymentBatchKey(payment)); + refundPriceMap.merge(payment.getWalletId(), payment.getPrice(), Math::addExact); + } + + refundPriceMap.forEach((walletId, totalPrice) -> { + if (walletManageMapper.updateWhenConsumptionRefund(walletId, totalPrice) != 1) { + throw new RuntimeException("钱包批量补偿失败"); + } + }); + + Map runningBalanceMap = new HashMap<>(); + lockedWalletMap.forEach((walletId, wallet) -> runningBalanceMap.put(walletId, wallet.getBalance())); + for (WalletBalanceTransactionDto payment : payments) { + if (!needRefundKeys.contains(paymentBatchKey(payment))) { + continue; + } + WalletManageEntity wallet = lockedWalletMap.get(payment.getWalletId()); + int afterBalance = Math.addExact(runningBalanceMap.get(payment.getWalletId()), payment.getPrice()); + runningBalanceMap.put(payment.getWalletId(), afterBalance); + WalletTransactionEntity refund = buildWalletTransaction(wallet, payment.getWalletId(), payment.getPrice(), + afterBalance, payment.getBizId(), WalletBizEnum.REFUND); + if (!save(refund)) { + throw new RuntimeException("保存钱包补偿流水失败"); + } + } + // 即使 PAYMENT 尚未到达也记录订单取消终态;该标记不会伪造 REFUND,仅阻止迟到请求继续扣款。 + payments.forEach(payment -> markPaymentCancelled(payment, rollbackMarkerKeys)); + return true; + } + + /** + * 校验并复制批量支付参数,拒绝空明细、非支付业务、透支和重复的钱包业务号。 + * + * @param dtoList 调用方提交的批量支付或补偿明细 + * @return 保持原请求顺序的独立列表 + */ + private List validatePaymentBatch(List dtoList) { + if (ObjectUtil.isEmpty(dtoList) || dtoList.stream().anyMatch(Objects::isNull)) { + throw new RuntimeException("钱包批量交易明细不能为空"); + } + Set businessKeys = new HashSet<>(); + for (WalletBalanceTransactionDto dto : dtoList) { + if (dto.getWalletId() == null || dto.getPrice() == null || dto.getPrice() <= 0 + || dto.getBizId() == null || dto.getBizId().isBlank()) { + throw new RuntimeException("钱包批量交易明细不完整"); + } + if (!Objects.equals(dto.getBizType(), WalletBizEnum.PAYMENT) || Boolean.TRUE.equals(dto.getForce())) { + throw new RuntimeException("钱包批量交易仅支持非透支支付"); + } + if (!businessKeys.add(paymentBatchKey(dto))) { + throw new RuntimeException("请勿重复提交同一钱包业务号"); + } + } + return new ArrayList<>(dtoList); + } + + /** + * 按钱包 ID 升序锁定批次涉及的钱包,统一并发顺序并返回钱包快照。 + * + * @param payments 已完成基础校验的支付明细 + * @return 按钱包 ID 索引的锁定钱包 + */ + private Map lockBatchWallets(List payments) { + Map walletMap = new LinkedHashMap<>(); + payments.stream().map(WalletBalanceTransactionDto::getWalletId).distinct().sorted().forEach(walletId -> { + WalletManageEntity wallet = walletManageMapper.selectByIdForUpdate(walletId); + if (wallet == null) { + throw new RuntimeException("扣款失败,用户钱包不存在"); + } + walletMap.put(walletId, wallet); + }); + return walletMap; + } + + /** + * 构造钱包内唯一的订单业务键,用于在批次内识别重复明细和关联已存在流水。 + * + * @param dto 单笔支付明细 + * @return 钱包 ID 与订单业务号组成的稳定键 + */ + private String paymentBatchKey(WalletBalanceTransactionDto dto) { + return dto.getWalletId() + ":" + dto.getBizId(); + } + + /** + * 按稳定业务键顺序获取整批订单交易锁,并把释放动作绑定到当前数据库事务完成之后。 + * 这样补偿返回时可以确定同一订单的扣款请求不在并发执行,数据库行锁也不会在 Redis 锁之前释放。 + * + * @param payments 已完成基础校验的支付或补偿明细 + * @param rollbackMarkerKeys 当前补偿事务写入、需要在事务回滚时删除的取消标记键;扣款场景传空列表 + */ + private void acquirePaymentOperationLocks(List payments, List rollbackMarkerKeys) { + List locks = new ArrayList<>(); + try { + for (String businessKey : payments.stream().map(this::paymentBatchKey).distinct().sorted().toList()) { + RLock lock = redisson.getLock(PAYMENT_OPERATION_LOCK_PREFIX + businessKey); + if (!lock.tryLock(PAYMENT_OPERATION_LOCK_WAIT_SECONDS, TimeUnit.SECONDS)) { + throw new RuntimeException("订单钱包交易正在处理中,请稍后重试"); + } + locks.add(lock); + } + registerPaymentOperationLockRelease(locks, rollbackMarkerKeys); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + releasePaymentOperationLocks(locks); + throw new RuntimeException("订单钱包交易等待已中断,请稍后重试", exception); + } catch (RuntimeException exception) { + releasePaymentOperationLocks(locks); + throw exception; + } + } + + /** + * 将分布式锁释放绑定到事务完成阶段,确保本地 PAYMENT/REFUND 流水提交前同一业务号不会被另一请求处理。 + * + * @param locks 当前线程已经获取的订单交易锁 + * @param rollbackMarkerKeys 当前补偿事务写入的取消标记键,事务回滚时必须在释放锁前删除 + */ + private void registerPaymentOperationLockRelease(List locks, List rollbackMarkerKeys) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + throw new RuntimeException("钱包批量交易事务状态异常,请稍后重试"); + } + List transactionLocks = List.copyOf(locks); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** + * 无论事务提交或回滚,都按获取顺序的逆序释放当前线程持有的订单交易锁。 + * + * @param status Spring 事务完成状态 + */ + @Override + public void afterCompletion(int status) { + if (status == TransactionSynchronization.STATUS_ROLLED_BACK) { + deletePaymentCancelledMarkers(rollbackMarkerKeys); + } + releasePaymentOperationLocks(transactionLocks); + } + }); + } + + /** + * 删除补偿事务回滚前已经写入的订单取消标记。删除发生在订单交易锁释放前,避免并发扣款看到错误终态。 + * + * @param markerKeys 当前回滚事务写入的取消标记键 + */ + private void deletePaymentCancelledMarkers(List markerKeys) { + for (String markerKey : markerKeys) { + try { + redisson.getBucket(markerKey).delete(); + } catch (Exception exception) { + // 删除失败时保留取消标记比允许已回滚批次发生迟到扣款更安全,同时记录键供运维核查。 + log.error("|{}|订单支付取消标记回滚失败,markerKey:|{}|", TAG, markerKey, exception); + } + } + } + + /** + * 逆序释放当前线程持有的订单交易锁;单个锁释放失败只记录日志,其他锁仍继续释放。 + * + * @param locks 待释放的订单交易锁 + */ + private void releasePaymentOperationLocks(List locks) { + ListIterator iterator = locks.listIterator(locks.size()); + while (iterator.hasPrevious()) { + RLock lock = iterator.previous(); + try { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } catch (Exception exception) { + log.error("|{}|订单钱包交易锁释放失败,lockKey:|{}|", TAG, lock.getName(), exception); + } + } + } + + /** + * 判断订单支付是否已被调用方补偿终止。该判断在订单交易锁内执行,防止检查后标记被并发改写。 + * + * @param payment 单笔订单支付明细 + * @return {@code true} 表示该订单禁止再产生新的 PAYMENT 流水 + */ + private boolean isPaymentCancelled(WalletBalanceTransactionDto payment) { + return redisson.getBucket(paymentCancelledMarkerKey(payment)).isExists(); + } + + /** + * 记录有期限的订单支付取消终态。补偿接口只在存在 PAYMENT 时写 REFUND,标记本身不参与财务流水统计。 + * + * @param payment 单笔已进入补偿流程的订单支付明细 + * @param rollbackMarkerKeys 当前事务回滚时需要删除的标记键集合 + */ + private void markPaymentCancelled(WalletBalanceTransactionDto payment, List rollbackMarkerKeys) { + String markerKey = paymentCancelledMarkerKey(payment); + boolean created = redisson.getBucket(markerKey).trySet( + PAYMENT_CANCELLED_MARKER_VALUE, PAYMENT_CANCELLED_MARKER_TTL_DAYS, TimeUnit.DAYS); + if (created) { + rollbackMarkerKeys.add(markerKey); + } + } + + /** + * 构造钱包内订单支付取消标记键,不包含姓名、卡号等敏感信息。 + * + * @param payment 单笔订单支付明细 + * @return 由钱包 ID 和订单业务号组成的 Redis 键 + */ + private String paymentCancelledMarkerKey(WalletBalanceTransactionDto payment) { + return PAYMENT_CANCELLED_MARKER_PREFIX + paymentBatchKey(payment); + } + private WalletTransactionEntity findBizTransaction(Long walletId, String bizId, WalletBizEnum bizType) { return baseMapper.selectOne(new LambdaQueryWrapper () .eq(WalletTransactionEntity::getWalletId, walletId) diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchAutoRefundConfigServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchAutoRefundConfigServiceImpl.java new file mode 100644 index 000000000..6b718e7f2 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchAutoRefundConfigServiceImpl.java @@ -0,0 +1,254 @@ +package com.bsd.pjcloud.wallet.service.impl.mch; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.common.core.util.BeanUtils; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.wallet.api.constant.mch.MchAutoRefundRuleTypeEnum; +import com.bsd.pjcloud.wallet.api.dto.MchManageDto; +import com.bsd.pjcloud.wallet.api.dto.mch.MchAutoRefundConfigBatchQueryDto; +import com.bsd.pjcloud.wallet.api.dto.mch.MchAutoRefundConfigDto; +import com.bsd.pjcloud.wallet.api.entity.mch.MchAutoRefundConfigEntity; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo; +import com.bsd.pjcloud.wallet.mapper.mch.MchAutoRefundConfigMapper; +import com.bsd.pjcloud.wallet.service.MchAutoRefundConfigService; +import com.bsd.pjcloud.wallet.service.MchManageService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.List; +import java.util.Objects; + +/** + * 商户自动退款配置实现。配置开关修改与 Wallet 自动退款终审共享同一配置行锁。 + */ +@Service +@Validated +@RequiredArgsConstructor +public class MchAutoRefundConfigServiceImpl extends ServiceImpl + implements MchAutoRefundConfigService { + + /** + * 首期唯一支持的硬件预约订单类型编码,对应 HardOrderTypeEnum.RESERVE。 + */ + private static final String SUPPORTED_ORDER_TYPE = "2"; + + /** + * 支付限时规则的默认小时数,管理端首次打开配置时使用 24 小时。 + */ + private static final int DEFAULT_PAY_TIME_LIMIT_HOURS = 24; + + /** + * 支付限时规则允许的最小小时数。 + */ + private static final int MIN_PAY_TIME_LIMIT_HOURS = 1; + + /** + * 支付限时规则允许的最大小时数,避免配置产生异常长的免审窗口。 + */ + private static final int MAX_PAY_TIME_LIMIT_HOURS = 168; + + /** + * 商户服务,用于管理接口校验目标商户属于当前用户的数据范围。 + */ + private final MchManageService mchManageService; + + /** + * 查询管理端配置前先校验商户数据范围,未配置时返回空数据而不是自动创建记录。 + * + * @param mchId 商户 ID + * @param orderType 硬件订单类型编码 + * @return 匹配配置;未配置时返回 {@code null} + */ + @Override + public MchAutoRefundConfigVo getForManagement(Long mchId, String orderType) { + validateMerchantScope(mchId); + validateOrderType(orderType); + return getExact(mchId, orderType); + } + + /** + * 以商户和订单类型唯一键为准执行单行新增或修改,配置主键存在时禁止换绑商户。 + * + * @param dto 管理端配置请求 + * @return 保存后的完整配置 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public MchAutoRefundConfigVo saveOrUpdateConfig(MchAutoRefundConfigDto dto) { + validateMerchantScope(dto.getMchId()); + validateConfig(dto.getOrderType(), dto.getEnabled(), dto.getRuleType(), dto.getPayTimeLimitHours()); + + MchAutoRefundConfigEntity exact = baseMapper.selectExactForUpdate(dto.getMchId(), dto.getOrderType()); + MchAutoRefundConfigEntity target; + if (dto.getId() != null) { + target = baseMapper.selectByIdForUpdate(dto.getId()); + if (target == null) { + throw new RuntimeException("自动退款配置不存在"); + } + if (!Objects.equals(target.getMchId(), dto.getMchId()) + || !Objects.equals(target.getOrderType(), dto.getOrderType())) { + throw new RuntimeException("自动退款配置不允许换绑商户或订单类型"); + } + if (exact != null && !Objects.equals(exact.getId(), target.getId())) { + throw new RuntimeException("当前商户订单类型已存在自动退款配置"); + } + } else { + target = exact == null ? new MchAutoRefundConfigEntity() : exact; + } + + target.setMchId(dto.getMchId()); + target.setOrderType(dto.getOrderType()); + target.setEnabled(dto.getEnabled()); + target.setRuleType(dto.getRuleType()); + target.setPayTimeLimitHours(resolvePayTimeLimitHours(dto.getRuleType(), dto.getPayTimeLimitHours())); + if (target.getId() == null) { + if (baseMapper.insert(target) != 1) { + throw new RuntimeException("新增自动退款配置失败"); + } + } else if (baseMapper.updateById(target) != 1) { + throw new RuntimeException("修改自动退款配置失败"); + } + return BeanUtils.toBean(target, MchAutoRefundConfigVo.class); + } + + /** + * 按当前租户、商户和订单类型精确查询配置。 + * + * @param mchId 商户 ID + * @param orderType 硬件订单类型编码 + * @return 匹配配置;未配置时返回 {@code null} + */ + @Override + public MchAutoRefundConfigVo getExact(Long mchId, String orderType) { + validateOrderType(orderType); + MchAutoRefundConfigEntity entity = baseMapper.selectOne(new LambdaQueryWrapper () + .eq(MchAutoRefundConfigEntity::getMchId, mchId) + .eq(MchAutoRefundConfigEntity::getOrderType, orderType)); + return entity == null ? null : BeanUtils.toBean(entity, MchAutoRefundConfigVo.class); + } + + /** + * 批量查询会过滤空商户 ID 并去重,避免异常请求扩大 SQL 参数规模。 + * + * @param dto 商户列表和订单类型 + * @return 匹配配置列表 + */ + @Override + public List listBatch(MchAutoRefundConfigBatchQueryDto dto) { + validateOrderType(dto.getOrderType()); + List mchIds = dto.getMchIds().stream().filter(Objects::nonNull).distinct().toList(); + if (mchIds.isEmpty()) { + return List.of(); + } + List entities = baseMapper.selectList(new LambdaQueryWrapper () + .in(MchAutoRefundConfigEntity::getMchId, mchIds) + .eq(MchAutoRefundConfigEntity::getOrderType, dto.getOrderType())); + return BeanUtils.toBean(entities, MchAutoRefundConfigVo.class); + } + + /** + * 仅锁定配置行,供 Wallet 在同一事务中先判断售后 ID 是否已经完成退款。 + * 已完成的幂等请求不产生新资金,因此即使开关随后关闭,也应允许返回原到账结果。 + * + * @param configId 订单快照和任务引用的配置主键 + * @return 已锁定配置 + */ + @Override + public MchAutoRefundConfigEntity lockForUpdate(Long configId) { + if (configId == null) { + throw new RuntimeException("自动退款配置不能为空"); + } + MchAutoRefundConfigEntity config = baseMapper.selectByIdForUpdate(configId); + if (config == null) { + throw new RuntimeException("自动退款配置不存在"); + } + return config; + } + + /** + * 自动退款资金入账前锁定配置并执行最后一次开关和规则校验;关闭返回后不会再有新请求通过此方法。 + * + * @param configId 订单快照和任务引用的配置主键 + * @return 已锁定且可用的配置 + */ + @Override + public MchAutoRefundConfigEntity requireEnabledForUpdate(Long configId) { + MchAutoRefundConfigEntity config = lockForUpdate(configId); + if (!Objects.equals(config.getEnabled(), YesNoEnum.YES.getCode())) { + throw new RuntimeException("自动退款开关已关闭"); + } + validateConfig(config.getOrderType(), config.getEnabled(), config.getRuleType(), config.getPayTimeLimitHours()); + return config; + } + + /** + * 校验目标商户是否在当前用户数据范围内,父店和分店按各自商户 ID 独立判断。 + * + * @param mchId 待访问商户 ID + */ + private void validateMerchantScope(Long mchId) { + if (mchId == null) { + throw new RuntimeException("商户不能为空"); + } + List scoped = mchManageService.scopeList(MchManageDto.builder().ids(List.of(mchId)).build(), + DataScope.of().only(true)); + if (scoped == null || scoped.stream().noneMatch(item -> Objects.equals(item.getId(), mchId))) { + throw new RuntimeException("无权配置当前商户"); + } + } + + /** + * 校验首期订单类型协议,避免配置记录被其他订单流程提前复用。 + * + * @param orderType 硬件订单类型编码 + */ + private void validateOrderType(String orderType) { + if (!Objects.equals(SUPPORTED_ORDER_TYPE, orderType)) { + throw new RuntimeException("首期仅支持预约订单自动退款"); + } + } + + /** + * 校验开关、规则和小时范围;关闭配置也必须保存完整有效规则,重新开启时才能直接恢复任务。 + * + * @param orderType 硬件订单类型编码 + * @param enabled 开关编码 + * @param ruleType 截止规则名称 + * @param payTimeLimitHours 支付后限时小时数 + */ + private void validateConfig(String orderType, String enabled, String ruleType, Integer payTimeLimitHours) { + validateOrderType(orderType); + if (!Objects.equals(enabled, YesNoEnum.YES.getCode()) && !Objects.equals(enabled, YesNoEnum.NO.getCode())) { + throw new RuntimeException("自动退款开关值无效"); + } + MchAutoRefundRuleTypeEnum rule = MchAutoRefundRuleTypeEnum.valueOfName(ruleType); + if (rule == null) { + throw new RuntimeException("自动退款规则无效"); + } + if (rule == MchAutoRefundRuleTypeEnum.PAY_TIME_LIMIT) { + int hours = payTimeLimitHours == null ? DEFAULT_PAY_TIME_LIMIT_HOURS : payTimeLimitHours; + if (hours < MIN_PAY_TIME_LIMIT_HOURS || hours > MAX_PAY_TIME_LIMIT_HOURS) { + throw new RuntimeException("支付后自动退款时限必须在1至168小时之间"); + } + } + } + + /** + * 规范化支付限时小时数;周期规则不持久化无意义小时值。 + * + * @param ruleType 截止规则名称 + * @param payTimeLimitHours 管理端提交的小时数 + * @return 支付规则的有效小时数,周期规则返回 {@code null} + */ + private Integer resolvePayTimeLimitHours(String ruleType, Integer payTimeLimitHours) { + if (MchAutoRefundRuleTypeEnum.PAY_TIME_LIMIT.name().equals(ruleType)) { + return payTimeLimitHours == null ? DEFAULT_PAY_TIME_LIMIT_HOURS : payTimeLimitHours; + } + return null; + } +} 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 5b770ec12..d62fbe661 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 @@ -36,6 +36,8 @@ import com.bsd.pjcloud.wallet.api.vo.recharge.app.GuardianWardRechargeItemVo; import com.bsd.pjcloud.wallet.api.vo.recharge.app.GuardianWardRechargePackageVo; import com.bsd.pjcloud.wallet.api.vo.recharge.app.GuardianWardRechargeResultVo; import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargeMapper; +import com.bsd.pjcloud.wallet.mapper.WalletManageMapper; +import com.bsd.pjcloud.wallet.service.MchAutoRefundConfigService; import com.bsd.pjcloud.wallet.service.MchManageService; import com.bsd.pjcloud.wallet.service.WalletManageService; import com.bsd.pjcloud.wallet.service.WalletRechargePackageService; @@ -47,6 +49,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.dao.DuplicateKeyException; import org.springframework.validation.annotation.Validated; import java.time.LocalDateTime; @@ -76,6 +79,12 @@ public class WalletRechargeServiceImpl extends ServiceImpl captor = ArgumentCaptor.forClass(MchAutoRefundConfigEntity.class); + verify(configMapper, org.mockito.Mockito.times(2)).insert(captor.capture()); + assertEquals(List.of(1, 168), captor.getAllValues().stream() + .map(MchAutoRefundConfigEntity::getPayTimeLimitHours).toList()); + } + + /** + * 支付后限时规则未提交小时数时应按协议保存默认 24 小时。 + */ + @Test + void payTimeLimitShouldUseTwentyFourHourDefault() { + service.saveOrUpdateConfig(config(MchAutoRefundRuleTypeEnum.PAY_TIME_LIMIT.name(), null)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MchAutoRefundConfigEntity.class); + verify(configMapper).insert(captor.capture()); + assertEquals(24, captor.getValue().getPayTimeLimitHours()); + } + + /** + * 支付后限时规则应拒绝小于 1 或大于 168 小时的配置。 + */ + @Test + void payTimeLimitShouldRejectValuesOutsideClosedInterval() { + assertThrows(RuntimeException.class, + () -> service.saveOrUpdateConfig(config(MchAutoRefundRuleTypeEnum.PAY_TIME_LIMIT.name(), 0))); + assertThrows(RuntimeException.class, + () -> service.saveOrUpdateConfig(config(MchAutoRefundRuleTypeEnum.PAY_TIME_LIMIT.name(), 169))); + } + + /** + * 周期预约截止规则不应持久化无意义的支付后小时数。 + */ + @Test + void appointmentCycleRuleShouldClearPayTimeLimitHours() { + service.saveOrUpdateConfig(config(MchAutoRefundRuleTypeEnum.APPT_CYCLE_CLOSE.name(), 24)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MchAutoRefundConfigEntity.class); + verify(configMapper).insert(captor.capture()); + assertNull(captor.getValue().getPayTimeLimitHours()); + } + + /** + * 构造固定测试商户的开启配置。 + * + * @param ruleType 自动退款规则名称 + * @param hours 支付后小时数 + * @return 管理端配置请求 + */ + private MchAutoRefundConfigDto config(String ruleType, Integer hours) { + MchAutoRefundConfigDto dto = new MchAutoRefundConfigDto(); + dto.setMchId(10L); + dto.setOrderType("2"); + dto.setEnabled("1"); + dto.setRuleType(ruleType); + dto.setPayTimeLimitHours(hours); + return dto; + } +} 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 new file mode 100644 index 000000000..bd7e64fe3 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImplTest.java @@ -0,0 +1,220 @@ +package com.bsd.pjcloud.wallet.service.impl.recharge; + +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.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.mch.MchAutoRefundConfigEntity; +import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity; +import com.bsd.pjcloud.wallet.mapper.WalletManageMapper; +import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargeMapper; +import com.bsd.pjcloud.wallet.service.MchAutoRefundConfigService; +import com.bsd.pjcloud.wallet.service.MchManageService; +import com.bsd.pjcloud.wallet.service.WalletManageService; +import com.bsd.pjcloud.wallet.service.WalletRechargePackageService; +import com.bsd.pjcloud.wallet.service.WalletRechargeService; +import com.bsd.pjcloud.wallet.service.WalletTransactionService; +import com.bsd.pjcloud.wallet.service.WalletTypeService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 钱包退款配置终审、售后幂等和唯一键竞态测试。 + */ +@ExtendWith(MockitoExtension.class) +class WalletRechargeServiceImplTest { + + /** 钱包充值服务自代理 Mock。 */ + @Mock + private WalletRechargeService walletRechargeService; + /** 钱包充值套餐服务 Mock。 */ + @Mock + private WalletRechargePackageService walletRechargePackageService; + /** 钱包管理服务 Mock。 */ + @Mock + private WalletManageService walletManageService; + /** 钱包交易服务 Mock,用于验证余额只增加一次。 */ + @Mock + private WalletTransactionService walletTransactionService; + /** 钱包行锁 Mapper Mock。 */ + @Mock + private WalletManageMapper walletManageMapper; + /** 自动退款配置行锁服务 Mock。 */ + @Mock + private MchAutoRefundConfigService configService; + /** 钱包类型服务 Mock。 */ + @Mock + private WalletTypeService walletTypeService; + /** 商户服务 Mock。 */ + @Mock + private MchManageService mchManageService; + /** 用户关系远程服务 Mock。 */ + @Mock + private RemoteUserRelationService remoteUserRelationService; + /** 用户远程服务 Mock。 */ + @Mock + private RemoteUserService remoteUserService; + /** 用户人脸远程服务 Mock。 */ + @Mock + private RemoteUserFaceService remoteUserFaceService; + /** Hardware 订单远程服务 Mock。 */ + @Mock + private RemoteHardOrderService remoteHardOrderService; + /** 钱包退款记录 Mapper Mock。 */ + @Mock + private WalletRechargeMapper rechargeMapper; + + /** 被测钱包充值服务。 */ + private WalletRechargeServiceImpl service; + + /** + * 初始化服务、注入 Mapper,并准备固定资金目标钱包。 + */ + @BeforeEach + void setUp() { + service = new WalletRechargeServiceImpl(walletRechargeService, walletRechargePackageService, walletManageService, + walletTransactionService, walletManageMapper, configService, walletTypeService, mchManageService, + remoteUserRelationService, remoteUserService, remoteUserFaceService, remoteHardOrderService); + ReflectionTestUtils.setField(service, "baseMapper", rechargeMapper); + WalletManageEntity wallet = new WalletManageEntity(); + wallet.setId(20L); + when(walletManageMapper.selectByIdForUpdate(20L)).thenReturn(wallet); + } + + /** + * 新自动退款必须先锁配置、再锁钱包,确认不存在幂等记录后才执行开关终审。 + */ + @Test + void automaticRefundShouldLockConfigBeforeWallet() { + MchAutoRefundConfigEntity config = new MchAutoRefundConfigEntity(); + config.setId(30L); + when(configService.lockForUpdate(30L)).thenReturn(config); + when(configService.requireEnabledForUpdate(30L)).thenReturn(config); + when(rechargeMapper.selectOne(any())).thenReturn(null); + when(rechargeMapper.insert(any(WalletRechargeEntity.class))).thenReturn(1); + + service.refund(refundRequest(100, 30L)); + + InOrder locks = inOrder(configService, walletManageMapper); + locks.verify(configService).lockForUpdate(30L); + locks.verify(walletManageMapper).selectByIdForUpdate(20L); + locks.verify(configService).requireEnabledForUpdate(30L); + } + + /** + * 开关关闭后,已按同一售后单到账的请求仍应返回原记录,不再次终审开关或增加余额。 + */ + @Test + void disabledAutomaticRefundShouldReturnPersistedRecordWithoutAddingBalance() { + MchAutoRefundConfigEntity disabledConfig = new MchAutoRefundConfigEntity(); + disabledConfig.setId(30L); + disabledConfig.setEnabled(YesNoEnum.NO.getCode()); + when(configService.lockForUpdate(30L)).thenReturn(disabledConfig); + when(rechargeMapper.selectOne(any())).thenReturn(existingRefund(20L, 100)); + + assertEquals(60L, service.refund(refundRequest(100, 30L)).getId()); + + verify(configService, never()).requireEnabledForUpdate(anyLong()); + verify(walletTransactionService, never()).addWalletBalance(anyLong(), anyInt(), anyString(), any(WalletBizEnum.class)); + } + + /** + * 相同售后单、钱包和金额的重复请求应直接返回原记录且不再增加余额。 + */ + @Test + void duplicateRefundShouldReturnExistingRecordWithoutAddingBalance() { + when(rechargeMapper.selectOne(any())).thenReturn(existingRefund(20L, 100)); + + assertEquals(20L, service.refund(refundRequest(100, null)).getWalletId()); + + verify(walletTransactionService, never()).addWalletBalance(anyLong(), anyInt(), anyString(), any(WalletBizEnum.class)); + } + + /** + * 重复售后单的金额或资金目标不一致时必须拒绝,不能复用原入账结果。 + */ + @Test + void duplicateRefundShouldRejectMismatchedAmount() { + when(rechargeMapper.selectOne(any())).thenReturn(existingRefund(20L, 99)); + + assertThrows(RuntimeException.class, () -> service.refund(refundRequest(100, null))); + verify(walletTransactionService, never()).addWalletBalance(anyLong(), anyInt(), anyString(), any(WalletBizEnum.class)); + } + + /** + * 未成功创建售后幂等记录时必须终止退款,禁止产生无法按售后单追溯的余额增加。 + */ + @Test + void failedRefundRecordInsertShouldNotAddBalance() { + when(rechargeMapper.selectOne(any())).thenReturn(null); + when(rechargeMapper.insert(any(WalletRechargeEntity.class))).thenReturn(0); + + assertThrows(RuntimeException.class, () -> service.refund(refundRequest(100, null))); + + verify(walletTransactionService, never()).addWalletBalance(anyLong(), anyInt(), anyString(), any(WalletBizEnum.class)); + } + + /** + * 并发插入命中售后唯一键时应重查原记录,不得再次增加余额。 + */ + @Test + void uniqueKeyConflictShouldRequeryWithoutAddingBalance() { + WalletRechargeEntity existing = existingRefund(20L, 100); + when(rechargeMapper.selectOne(any())).thenReturn(null, existing); + when(rechargeMapper.insert(any(WalletRechargeEntity.class))).thenThrow(new DuplicateKeyException("duplicate")); + + assertEquals(existing.getId(), service.refund(refundRequest(100, null)).getId()); + + verify(walletTransactionService, never()).addWalletBalance(anyLong(), anyInt(), anyString(), any(WalletBizEnum.class)); + } + + /** + * 构造固定售后单的退款请求。 + * + * @param price 退款金额,单位为分 + * @param configId 自动退款配置 ID;人工流程为空 + * @return 钱包退款请求 + */ + private WalletRechargeRefundDto refundRequest(Integer price, Long configId) { + return WalletRechargeRefundDto.builder().walletId(20L).orderId(40L).afterSaleId(50L) + .refundCode("hard_40_50").reason("订单退款").price(price).autoRefundConfigId(configId).build(); + } + + /** + * 构造已经成功持久化的幂等退款记录。 + * + * @param walletId 到账钱包 ID + * @param price 退款金额,单位为分 + * @return 原钱包退款记录 + */ + private WalletRechargeEntity existingRefund(Long walletId, Integer price) { + WalletRechargeEntity entity = new WalletRechargeEntity(); + entity.setId(60L); + entity.setWalletId(walletId); + entity.setPayRefundOrderId(50L); + entity.setRefundTotalPrice(price); + return entity; + } +}