diff --git a/db/20260813_add_wallet_transaction_tags.sql b/db/20260813_add_wallet_transaction_tags.sql new file mode 100644 index 000000000..cbe856152 --- /dev/null +++ b/db/20260813_add_wallet_transaction_tags.sql @@ -0,0 +1,6 @@ +-- 为充值导入记录及最终钱包流水补充财务统计标签;历史数据保持为空。 +ALTER TABLE wallet_recharge_import + ADD COLUMN tags varchar(512) DEFAULT NULL COMMENT '财务统计标签,多个wallet_transaction_tag字典值使用英文逗号分隔' AFTER price; + +ALTER TABLE wallet_transaction + ADD COLUMN tags varchar(512) DEFAULT NULL COMMENT '财务统计标签,多个wallet_transaction_tag字典值使用英文逗号分隔' AFTER balance; 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/db/800pjcloudx_wallet.sql b/db/800pjcloudx_wallet.sql index 42efd830b..c4583ce23 100644 --- a/db/800pjcloudx_wallet.sql +++ b/db/800pjcloudx_wallet.sql @@ -455,6 +455,7 @@ CREATE TABLE wallet_transaction title varchar(128) NOT NULL COMMENT '流水标题', price int NOT NULL COMMENT '交易金额, 单位分', balance int NOT NULL COMMENT '余额, 单位分', + tags varchar(512) DEFAULT NULL COMMENT '财务统计标签,多个wallet_transaction_tag字典值使用英文逗号分隔', create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人', create_time datetime DEFAULT NULL COMMENT '创建时间', update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人', @@ -536,6 +537,7 @@ CREATE TABLE wallet_recharge_import apply_id bigint NOT NULL COMMENT '申请id', before_price int DEFAULT NULL COMMENT '导入前的金额', price int NOT NULL COMMENT '交易金额, 单位分', + tags varchar(512) DEFAULT NULL COMMENT '财务统计标签,多个wallet_transaction_tag字典值使用英文逗号分隔', create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人', create_time datetime DEFAULT NULL COMMENT '创建时间', update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人', diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/annotation/ExcelDict.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/annotation/ExcelDict.java index 2b88ff1f1..828ceb593 100644 --- a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/annotation/ExcelDict.java +++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/annotation/ExcelDict.java @@ -9,6 +9,7 @@ import java.lang.annotation.Target; /** * Excel 字典字段。 *

导入时校验单元格内容是否为字典标签,导出模板时将同一字典标签写入下拉选项。

+ *

多选字段在普通 Excel 中使用英文逗号分隔多个标签;原生下拉仅负责提供选项,无法自动追加多次选择。

*/ @Documented @Target(ElementType.FIELD) @@ -20,4 +21,11 @@ public @interface ExcelDict { */ String value(); + /** + * 是否允许一个单元格填写多个字典标签。 + * + * @return {@code true} 时按英文逗号拆分并逐项校验,{@code false} 时整个单元格只能是一个字典标签 + */ + boolean multiple() default false; + } diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/annotation/ExcelTemplate.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/annotation/ExcelTemplate.java index b295cf396..1dc047170 100644 --- a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/annotation/ExcelTemplate.java +++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/annotation/ExcelTemplate.java @@ -8,11 +8,18 @@ import java.lang.annotation.Target; /** * Excel 模板增强。 - *

切面自动收集 {@link RequestExcel} 导入参数中的模型,下载接口无需感知文件名或具体业务模型。

+ *

切面默认自动收集当前服务 {@link RequestExcel} 导入参数中的模型;跨服务模型可通过注解参数显式补充。

*/ @Documented @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface ExcelTemplate { + /** + * 当前下载接口需要额外处理的 Excel 模型,用于补充当前服务无法自动扫描到的跨服务导入模型。 + * + * @return 需要参与模板字典下拉增强的模型类型;默认不额外补充模型 + */ + Class[] value() default {}; + } diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/aop/ExcelTemplateAspect.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/aop/ExcelTemplateAspect.java index 9ba53d648..6918a987f 100644 --- a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/aop/ExcelTemplateAspect.java +++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/aop/ExcelTemplateAspect.java @@ -63,7 +63,7 @@ public class ExcelTemplateAspect { Object result = joinPoint.proceed(wrappedArgs); wrapper.flushBuffer(); byte[] content = wrapper.getContentAsByteArray(); - byte[] enhancedContent = enhance(content, getModelClasses()); + byte[] enhancedContent = enhance(content, resolveModelClasses(excelTemplate)); response.setContentLength(enhancedContent.length); response.getOutputStream().write(enhancedContent); response.getOutputStream().flush(); @@ -72,6 +72,18 @@ public class ExcelTemplateAspect { throw new IllegalStateException("@ExcelTemplate 方法必须包含 HttpServletResponse 参数"); } + /** + * 合并注解显式模型与当前服务自动扫描模型,保证跨服务模板可增强且保留原有自动发现能力。 + * + * @param excelTemplate 当前下载方法的模板增强注解 + * @return 去重后的模板模型集合 + */ + private Class[] resolveModelClasses(ExcelTemplate excelTemplate) { + return java.util.stream.Stream.concat(Arrays.stream(excelTemplate.value()), Arrays.stream(getModelClasses())) + .distinct() + .toArray(Class[]::new); + } + private boolean isExcelTemplateRequest(Object[] args) { for (Object arg : args) { if (arg instanceof String value) { @@ -85,7 +97,7 @@ public class ExcelTemplateAspect { } /** - * 导入接口的 {@code @RequestExcel List} 是模板模型的唯一来源,新增导入模型无需再修改下载接口或注册表。 + * 自动收集当前服务导入接口中的 {@code @RequestExcel List} 模型,供未显式注册的同服务模板继续使用。 */ private Class[] getModelClasses() { Class[] cachedModelClasses = modelClasses; @@ -162,7 +174,7 @@ public class ExcelTemplateAspect { HeaderCell headerCell = findHeaderCell(sheet, headerName); if (headerCell != null) { ExcelDictDataValidationWriter.write(sheet, headerCell.columnIndex(), headerCell.rowIndex() + 1, - excelDict.value()); + excelDict.value(), excelDict.multiple()); enhanced = true; } } diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/dict/ExcelDictDataValidationWriter.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/dict/ExcelDictDataValidationWriter.java index f0837dce8..a9b73fb69 100644 --- a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/dict/ExcelDictDataValidationWriter.java +++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/dict/ExcelDictDataValidationWriter.java @@ -31,9 +31,15 @@ public final class ExcelDictDataValidationWriter { } /** - * 将字典标签写入隐藏页,并为目标列创建引用式下拉。 + * 将字典标签写入隐藏页,并为目标列创建引用式下拉;多选模式允许按英文逗号手工组合多个选项。 + * + * @param dataSheet 数据页 + * @param columnIndex 字典字段所在列下标 + * @param firstDataRowIndex 首行数据的下标 + * @param dictType 字典类型 + * @param multiple 是否允许一个单元格包含多个字典标签 */ - public static void write(Sheet dataSheet, int columnIndex, int firstDataRowIndex, String dictType) { + public static void write(Sheet dataSheet, int columnIndex, int firstDataRowIndex, String dictType, boolean multiple) { List labels = ExcelDictHelper.getLabels(dictType); if (labels.isEmpty()) { throw new IllegalStateException("Excel 字典未配置或没有可用项: " + dictType); @@ -41,7 +47,7 @@ public final class ExcelDictDataValidationWriter { Workbook workbook = dataSheet.getWorkbook(); String formulaName = writeDictColumn(workbook, dictType, labels); removeColumnValidations(dataSheet, columnIndex); - addValidation(dataSheet, columnIndex, firstDataRowIndex, formulaName); + addValidation(dataSheet, columnIndex, firstDataRowIndex, formulaName, multiple); } /** @@ -123,15 +129,30 @@ public final class ExcelDictDataValidationWriter { return false; } - private static void addValidation(Sheet sheet, int columnIndex, int firstDataRowIndex, String formulaName) { + /** + * 为目标列添加字典下拉。多选时关闭 Excel 原生错误拦截,否则逗号组合值会被单选列表拒绝。 + * + * @param sheet 数据页 + * @param columnIndex 字典字段所在列下标 + * @param firstDataRowIndex 首行数据的下标 + * @param formulaName 隐藏字典区域的名称 + * @param multiple 是否允许按英文逗号组合多个标签 + */ + private static void addValidation(Sheet sheet, int columnIndex, int firstDataRowIndex, String formulaName, boolean multiple) { DataValidationHelper helper = sheet.getDataValidationHelper(); DataValidationConstraint constraint = helper.createFormulaListConstraint(formulaName); CellRangeAddressList range = new CellRangeAddressList(firstDataRowIndex, sheet.getWorkbook().getSpreadsheetVersion().getLastRowIndex(), columnIndex, columnIndex); DataValidation validation = helper.createValidation(constraint, range); validation.setEmptyCellAllowed(true); - validation.setShowErrorBox(true); - validation.createErrorBox("填写错误", "请选择下拉中的有效字典项"); + validation.setShowErrorBox(!multiple); + if (multiple) { + validation.setShowPromptBox(true); + validation.createPromptBox("填写说明", "可填写多个选项,请使用英文逗号分隔"); + } + else { + validation.createErrorBox("填写错误", "请选择下拉中的有效字典项"); + } sheet.addValidationData(validation); } diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/handler/DefaultAnalysisEventListener.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/handler/DefaultAnalysisEventListener.java index ade9e13d9..4bd9519b3 100644 --- a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/handler/DefaultAnalysisEventListener.java +++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/handler/DefaultAnalysisEventListener.java @@ -12,6 +12,7 @@ import org.springframework.util.StringUtils; import jakarta.validation.ConstraintViolation; import java.lang.reflect.Field; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; @@ -67,7 +68,10 @@ public class DefaultAnalysisEventListener extends ListAnalysisEventListener validateExcelDict(Object data) { Set messages = new LinkedHashSet<>(); @@ -82,14 +86,24 @@ public class DefaultAnalysisEventListener extends ListAnalysisEventListener labels = dictLabelCache.computeIfAbsent(excelDict.value(), ExcelDictHelper::getLabels); String fieldName = getExcelFieldName(field); if (labels.isEmpty()) { messages.add(fieldName + "字典未配置或没有可用项"); } - else if (!labels.contains(label)) { - messages.add(fieldName + ":" + label + ",不在可选项中"); + else { + List selectedLabels = excelDict.multiple() + ? Arrays.stream(fieldText.split(",", -1)).map(String::trim).toList() + : List.of(fieldText); + if (selectedLabels.stream().anyMatch(label -> !StringUtils.hasText(label))) { + messages.add(fieldName + "包含空选项,请使用英文逗号正确分隔"); + continue; + } + List invalidLabels = selectedLabels.stream().filter(label -> !labels.contains(label)).distinct().toList(); + if (!invalidLabels.isEmpty()) { + messages.add(fieldName + ":" + String.join(",", invalidLabels) + ",不在可选项中"); + } } } catch (IllegalAccessException exception) { 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 mchIds; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateDailyMenuReserveOrdersDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateDailyMenuReserveOrdersDto.java index 50f7bebd8..42579019d 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateDailyMenuReserveOrdersDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateDailyMenuReserveOrdersDto.java @@ -14,7 +14,7 @@ import java.util.List; * App 每日菜谱多日期、多餐段批量订餐请求。 * *

一个分组对应一个父购物车和一个预约日期餐段,服务端会为每个分组下的每位实际用餐人 - * 创建独立订单,并统一从当前付款人选择的钱包扣款。

+ * 创建独立订单,并从各实际用餐人本人已经开通的 EAT 饭卡扣款。

*/ @Data public class HardAppCreateDailyMenuReserveOrdersDto { @@ -34,9 +34,9 @@ public class HardAppCreateDailyMenuReserveOrdersDto { private String payType; /** - * 付款人本次选择的钱包类型 ID,服务端会按当前登录用户再次校验钱包归属和余额。 + * 旧客户端兼容字段。服务端不再使用该值选择钱包,实际扣款钱包固定按每位用餐人的 EAT 饭卡解析; + * 新客户端可以不传,旧客户端继续传入也不会改变结算目标。 */ - @NotNull(message = "钱包类型不能为空") private Long walletTypeId; /** diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateReserveOrdersDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateReserveOrdersDto.java index 27b7d0666..df094055b 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateReserveOrdersDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateReserveOrdersDto.java @@ -2,6 +2,7 @@ package com.bsd.pjcloud.hard.api.dto.order.app; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import lombok.Data; import java.time.LocalDate; @@ -25,6 +26,12 @@ public class HardAppCreateReserveOrdersDto { @NotEmpty(message = "用餐人不能为空") private List dinerUserIds; + /** + * App 支付平台类型。新客户端固定提交钱包支付编码 30;旧客户端未传时按 30 兼容处理,其他编码拒绝进入饭卡直扣。 + */ + @Pattern(regexp = "30", message = "批量订餐暂仅支持钱包支付") + private String payType; + private Long apptId; private Long apptRuleId; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppRefundApplyDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppRefundApplyDto.java index 9116c82fb..bb6939abb 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppRefundApplyDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppRefundApplyDto.java @@ -42,7 +42,7 @@ public class HardAppRefundApplyDto { /** * 用户填写的问题描述,便于商户审核本次所选商品。 */ - @NotBlank(message = "相关描述不能为空") + // @NotBlank(message = "相关描述不能为空") @Schema(description = "相关描述") private String applyDescription; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundAttemptLogEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundAttemptLogEntity.java new file mode 100644 index 000000000..3d8d69811 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundAttemptLogEntity.java @@ -0,0 +1,141 @@ +package com.bsd.pjcloud.hard.api.entity.order; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 自动退款单次尝试只读日志。每个任务的 attempt_no 严格递增且由唯一索引防止重复写入。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@TenantTable +@TableName("hard_auto_refund_attempt_log") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "自动退款尝试日志") +public class HardAutoRefundAttemptLogEntity extends Model { + + /** + * 日志主键,使用 MyBatis-Plus 雪花 ID。 + */ + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "日志主键") + private Long id; + + /** + * 所属自动退款任务 ID。 + */ + @Schema(description = "任务ID") + private Long taskId; + + /** + * 所属售后单 ID,便于不经过任务表直接查询完整尝试历史。 + */ + @Schema(description = "售后单ID") + private Long afterSaleId; + + /** + * 任务内递增尝试序号,包含首次开关关闭跳过记录。 + */ + @Schema(description = "尝试序号") + private Integer attemptNo; + + /** + * 触发来源编码,对应 HardAutoRefundTriggerTypeEnum。 + */ + @Schema(description = "触发来源") + private String triggerType; + + /** + * 尝试结果编码,对应 HardAutoRefundAttemptResultEnum。 + */ + @Schema(description = "尝试结果") + private String resultStatus; + + /** + * 稳定钱包退款业务号,格式为 hard_{orderId}_{afterSaleId}。 + */ + @Schema(description = "钱包退款业务号") + private String refundCode; + + /** + * 人工技术重试的操作用户 ID;自动触发时为空。 + */ + @Schema(description = "操作用户ID") + private Long operatorUserId; + + /** + * 本次尝试开始时间,使用服务器默认时区。 + */ + @Schema(description = "开始时间") + private LocalDateTime startedTime; + + /** + * 本次尝试完成时间;执行中临时记录允许为空。 + */ + @Schema(description = "完成时间") + private LocalDateTime finishedTime; + + /** + * 失败诊断信息,最多 2000 字符;成功和开关跳过时为空。 + */ + @Schema(description = "错误信息") + private String errorMessage; + + /** + * 创建人账号,由公共字段填充器写入。 + */ + @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-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java new file mode 100644 index 000000000..21024beb6 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java @@ -0,0 +1,159 @@ +package com.bsd.pjcloud.hard.api.entity.order; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Hardware 自动退款持久化任务。一个售后单最多创建一条任务,失败后由该记录持续驱动恢复。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@TenantTable +@TableName("hard_auto_refund_task") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "自动退款任务") +public class HardAutoRefundTaskEntity extends Model { + + /** + * 任务主键,使用 MyBatis-Plus 雪花 ID。 + */ + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "任务主键") + private Long id; + + /** + * 售后单 ID,是任务唯一业务键和钱包退款幂等键。 + */ + @Schema(description = "售后单ID") + private Long afterSaleId; + + /** + * 原 Hardware 订单 ID,用于订单级分布式锁和退款状态聚合。 + */ + @Schema(description = "原订单ID") + private Long orderId; + + /** + * 订单所属商户 ID,用于管理端数据范围和配置实时复核。 + */ + @Schema(description = "商户ID") + private Long mchId; + + /** + * 下单时快照引用的 Wallet 自动退款配置主键。 + */ + @Schema(description = "配置主键") + private Long configId; + + /** + * 下单时快照的规则名称,重试期间不跟随配置规则切换而改变。 + */ + @Schema(description = "自动退款规则快照") + private String ruleType; + + /** + * 用户申请免审退款的闭区间截止时间;任务创建后跨过该时间仍继续重试。 + */ + @Schema(description = "免审资格截止时间") + private LocalDateTime eligibilityDeadline; + + /** + * 当前任务状态编码,对应 HardAutoRefundTaskStatusEnum。 + */ + @Schema(description = "任务状态") + private String status; + + /** + * 已消耗的自动尝试次数,包含首次同步执行,最大为 8;开关跳过和人工重试不增加。 + */ + @Schema(description = "自动尝试次数") + private Integer autoAttemptCount; + + /** + * 全部审计尝试序号计数,包含自动、人工和首次开关关闭跳过,用于生成唯一 attempt_no。 + */ + @Schema(description = "总尝试次数") + private Integer totalAttemptCount; + + /** + * 等待重试任务的下一次到期时间;暂停、耗尽和成功状态允许为空。 + */ + @Schema(description = "下次重试时间") + private LocalDateTime nextRetryTime; + + /** + * 最近一次实际尝试或开关跳过的开始时间。 + */ + @Schema(description = "末次尝试时间") + private LocalDateTime lastAttemptTime; + + /** + * 钱包到账及 Hardware 状态全部提交成功的时间。 + */ + @Schema(description = "成功时间") + private LocalDateTime successTime; + + /** + * 最近一次失败的诊断信息,最多保存 2000 字符,不包含凭证或敏感资金数据。 + */ + @Schema(description = "末次错误") + private String lastError; + + /** + * 创建人账号,由公共字段填充器写入。 + */ + @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-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java index 2b48f4974..b866cf6cb 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java @@ -141,6 +141,24 @@ public class HardOrderEntity extends Model { @Schema(description = "预计使用日期,食堂预约订单取预约目标日期,其他订单取下单当天,精确到天") private LocalDate apptUseDate; + /** + * 下单时命中的商户自动退款配置主键快照;为空表示订单不具备免审资格。 + */ + @Schema(description = "自动退款配置主键快照") + private Long autoRefundConfigId; + + /** + * 下单时保存的自动退款规则名称;退款申请时不重新按新规则计算截止时间。 + */ + @Schema(description = "自动退款规则快照") + private String autoRefundRuleType; + + /** + * 免审退款申请截止时间,使用服务器默认时区并按 {@code now <= deadline} 判断。 + */ + @Schema(description = "自动退款申请截止时间") + private LocalDateTime autoRefundDeadline; + /** * 订单设备类型 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java index 21324cdff..cb1fec68d 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java @@ -10,6 +10,11 @@ import java.time.LocalDateTime; @Data public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserInterface, BaseDeptInterface, BaseOrderInterface { + /** + * 自动退款任务状态;为空表示当前售后仍走人工审核,非空时前端不得展示审核或取消操作。 + */ + private String autoRefundTaskStatus; + /** * 用户相关 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java new file mode 100644 index 000000000..bb2c31066 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java @@ -0,0 +1,45 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 自动退款任务管理响应,补充售后金额、用户和商户展示信息。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "自动退款任务管理响应") +public class HardAutoRefundTaskVo extends HardAutoRefundTaskEntity { + + /** + * 售后申请用户 ID。 + */ + @Schema(description = "用户ID") + private Long userId; + + /** + * 售后申请用户展示名称,远程用户服务异常时为空但不影响任务查询。 + */ + @Schema(description = "用户名称") + private String userName; + + /** + * 商户展示名称,远程商户服务异常时为空但保留商户 ID。 + */ + @Schema(description = "商户名称") + private String mchName; + + /** + * 售后单服务端核定退款金额,单位为分。 + */ + @Schema(description = "退款金额,单位为分") + private Integer refundPrice; + + /** + * 售后单当前状态,用于区分处理中和资金已完成。 + */ + @Schema(description = "售后状态") + private String afterSaleStatus; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppAutoRefundTaskVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppAutoRefundTaskVo.java new file mode 100644 index 000000000..8b6ab8823 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppAutoRefundTaskVo.java @@ -0,0 +1,44 @@ +package com.bsd.pjcloud.hard.api.vo.order.app; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * App 自动退款处理状态,只暴露用户理解进度所需字段,不返回内部错误详情。 + */ +@Data +@Schema(description = "App自动退款处理状态") +public class HardAppAutoRefundTaskVo { + + /** + * 售后单 ID,供退款进度页确认当前查询对象。 + */ + @Schema(description = "售后单ID") + private Long afterSaleId; + + /** + * 自动退款任务状态编码;不存在自动任务时接口返回空数据。 + */ + @Schema(description = "自动退款任务状态") + private String status; + + /** + * 自动尝试次数,开关暂停和人工技术重试不计入该值。 + */ + @Schema(description = "自动尝试次数") + private Integer autoAttemptCount; + + /** + * 等待重试状态下的下一次计划时间,其他状态允许为空。 + */ + @Schema(description = "下次重试时间") + private LocalDateTime nextRetryTime; + + /** + * 自动退款全部完成时间;处理中和暂停状态为空。 + */ + @Schema(description = "完成时间") + private LocalDateTime successTime; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppRefundApplyVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppRefundApplyVo.java index 2699614f6..bf06ee191 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppRefundApplyVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppRefundApplyVo.java @@ -55,6 +55,12 @@ public class HardAppRefundApplyVo { @Schema(description = "是否可以继续在线申请") private Boolean canApply; + /** + * 是否退回实际用餐人的饭卡。仅食堂预约钱包订单为 {@code true},其他订单仍按原支付渠道原路退款。 + */ + @Schema(description = "是否退回实际用餐人的饭卡") + private Boolean refundToDinerWallet; + /** * 最新售后单 ID;没有历史申请时为空。 */ @@ -85,6 +91,36 @@ public class HardAppRefundApplyVo { @Schema(description = "最新状态更新时间") private LocalDateTime latestUpdateTime; + /** + * 当前订单是否可在本次提交后进入免审自动退款,按订单快照、闭区间截止和实时开关共同判断。 + */ + @Schema(description = "是否具备自动退款资格") + private Boolean autoRefundEligible; + + /** + * 下单时快照的自动退款规则名称;历史无快照订单为空。 + */ + @Schema(description = "自动退款规则") + private String autoRefundRuleType; + + /** + * 下单时快照的免审申请截止时间;历史无快照订单为空。 + */ + @Schema(description = "自动退款截止时间") + private LocalDateTime autoRefundDeadline; + + /** + * 最新售后单关联的自动退款任务状态;人工审核售后为空。 + */ + @Schema(description = "自动退款任务状态") + private String autoRefundTaskStatus; + + /** + * 最新售后单是否允许用户取消;存在自动退款任务时始终为 {@code false}。 + */ + @Schema(description = "最新售后单是否允许取消") + private Boolean latestAfterSaleCancelable; + /** * 按原订单商品 ID 聚合后的退款商品列表。 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java new file mode 100644 index 000000000..a66b061ae --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java @@ -0,0 +1,155 @@ +package com.bsd.pjcloud.hard.controller.order; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes; +import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.idempotent.annotation.Idempotent; +import com.bsd.pjcloud.common.security.util.SecurityUtils; +import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto; +import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity; +import com.bsd.pjcloud.hard.api.vo.order.HardAutoRefundTaskVo; +import com.bsd.pjcloud.hard.api.vo.order.app.HardAppAutoRefundTaskVo; +import com.bsd.pjcloud.hard.service.HardAfterSaleService; +import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService; +import com.bsd.pjcloud.wallet.api.dto.MchManageDto; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Objects; + +/** + * 自动退款任务管理和 App 只读进度接口。 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/hard-auto-refund-task") +@Tag(description = "hard-auto-refund-task", name = "自动退款任务") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class HardAutoRefundTaskController { + + /** + * 自动退款任务状态机服务。 + */ + private final HardAutoRefundTaskService taskService; + + /** + * 售后服务,用于 App 查询任务前复用订单查看授权。 + */ + private final HardAfterSaleService hardAfterSaleService; + + /** + * 商户服务,用于管理接口按当前用户数据范围限制任务。 + */ + private final RemoteMchService remoteMchService; + + /** + * 按当前用户商户数据范围分页查询自动退款任务。 + * + * @param page 分页参数 + * @param dto 筛选条件 + * @return 任务分页 + */ + @GetMapping("/page") + @Operation(summary = "分页查询自动退款任务") + @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_view')") + public R> page(@ParameterObject Page page, + @ParameterObject HardAutoRefundTaskPageDto dto) { + List scopedMchIds = listScopedMerchantIds(); + if (scopedMchIds.isEmpty()) { + Page empty = new Page<>(page.getCurrent(), page.getSize(), 0L); + empty.setRecords(List.of()); + return R.ok(empty); + } + dto.setMchIds(scopedMchIds); + return R.ok(taskService.pageTasks(page, dto)); + } + + /** + * 查询指定任务全部只读尝试日志。 + * + * @param id 任务主键 + * @return 按尝试序号升序的日志列表 + */ + @GetMapping("/{id}/attempts") + @Operation(summary = "查询自动退款尝试日志") + @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_view')") + public R> attempts(@PathVariable Long id) { + validateTaskScope(id); + return R.ok(taskService.listAttempts(id)); + } + + /** + * 对重试耗尽任务额外执行一次技术重试。 + * + * @param id 任务主键 + * @return 操作成功标记;资金失败仍会返回成功受理并保持耗尽状态 + */ + @PostMapping("/{id}/retry") + @Operation(summary = "人工重新触发自动退款") + @Idempotent(expireTime = 5, info = "正在重新触发,请勿重复操作") + @PreAuthorize("@pms.hasPermission('hard_autoRefundTask_retry')") + public R retry(@PathVariable Long id) { + validateTaskScope(id); + taskService.manualRetry(id, SecurityUtils.getUser().getId()); + return R.ok(true); + } + + /** + * App 按售后单查询自动退款进度,人工审核售后返回空数据。 + * + * @param afterSaleId 售后单 ID + * @return 用户可见的任务状态 + */ + @ApiCryptoAes + @GetMapping("/app/status") + @Operation(summary = "App查询自动退款进度") + public R appStatus(@RequestParam Long afterSaleId) { + hardAfterSaleService.validateAppAfterSaleAccess(afterSaleId, SecurityUtils.getUser().getId()); + return R.ok(taskService.getAppTaskStatus(afterSaleId)); + } + + /** + * 校验任务商户属于当前管理用户数据范围。 + * + * @param taskId 任务主键 + */ + private void validateTaskScope(Long taskId) { + HardAutoRefundTaskEntity task = taskService.getById(taskId); + if (task == null) { + throw new RuntimeException("自动退款任务不存在"); + } + if (listScopedMerchantIds().stream().noneMatch(id -> Objects.equals(id, task.getMchId()))) { + throw new RuntimeException("无权访问当前自动退款任务"); + } + } + + /** + * 查询当前管理用户可访问的食堂商户 ID;远程异常拒绝请求,不能降级为全量任务。 + * + * @return 当前用户可访问商户 ID 列表 + */ + private List listScopedMerchantIds() { + MchManageDto query = MchManageDto.builder().typesList(List.of(MerchantTypeEnum.CANTEEN.getType())).build(); + R> response = remoteMchService.scopeList(query); + if (response == null || !response.isOk() || response.getData() == null) { + throw new RuntimeException("查询商户数据范围失败"); + } + return response.getData().stream().filter(Objects::nonNull).map(MchManageVo::getId).filter(Objects::nonNull).distinct().toList(); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java index 5fe194dac..d37804475 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java @@ -332,9 +332,9 @@ public class HardOrderController { } /** - * 当前用户使用自己的餐饮钱包,为本人和有效被监护人分别创建独立预约订单。 + * 当前用户为本人和有效被监护人代订,每笔订单从实际用餐人本人的 EAT 饭卡扣款。 */ - @Operation(summary = "app批量创建食堂预定订单", description = "每位用餐人创建一笔独立订单,统一从当前用户钱包扣款") + @Operation(summary = "app批量创建食堂预定订单", description = "每位用餐人创建一笔独立订单,并分别从本人饭卡扣款") @ApiCryptoAes @PostMapping("/app/create-reserve-orders") @Idempotent(expireTime = 30, diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJob.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJob.java new file mode 100644 index 000000000..2a6ae9314 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJob.java @@ -0,0 +1,57 @@ +package com.bsd.pjcloud.hard.job; + +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum; +import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity; +import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 自动退款任务补偿调度器。多实例允许同时扫描,订单 Redis 锁和任务行锁负责最终串行。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class HardAutoRefundTaskJob { + + /** + * 调度器触发间隔,单位毫秒,固定为 30 秒。 + */ + private static final long SCAN_INTERVAL_MILLIS = 30_000L; + + /** + * 单轮跨租户最多扫描任务数量,避免配置和钱包服务突发压力。 + */ + private static final int SCAN_BATCH_SIZE = 100; + + /** + * 自动退款任务服务。 + */ + private final HardAutoRefundTaskService taskService; + + /** + * 每 30 秒扫描到期或暂停任务,并按任务持久化租户恢复 Feign 和数据库上下文。 + */ + @Scheduled(fixedDelay = SCAN_INTERVAL_MILLIS, initialDelay = SCAN_INTERVAL_MILLIS) + public void retryDueTasks() { + List tasks = taskService.listScanCandidates(SCAN_BATCH_SIZE); + for (HardAutoRefundTaskEntity task : tasks) { + if (task.getTenantId() == null) { + log.warn("自动退款任务缺少租户ID,taskId:{}", task.getId()); + continue; + } + try { + TenantBroker.runAs(task.getTenantId(), tenantId -> + taskService.execute(task.getId(), HardAutoRefundTriggerTypeEnum.SCHEDULED, null)); + } catch (Exception exception) { + log.warn("自动退款调度执行异常,taskId:{},tenantId:{},exceptionType:{}", task.getId(), task.getTenantId(), + exception.getClass().getSimpleName()); + } + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundAttemptLogMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundAttemptLogMapper.java new file mode 100644 index 000000000..20b12888e --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundAttemptLogMapper.java @@ -0,0 +1,12 @@ +package com.bsd.pjcloud.hard.mapper.order; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 自动退款尝试日志 Mapper。业务仅新增并在同一尝试事务内完成结果更新,不提供删除能力。 + */ +@Mapper +public interface HardAutoRefundAttemptLogMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java new file mode 100644 index 000000000..41ea25c22 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java @@ -0,0 +1,51 @@ +package com.bsd.pjcloud.hard.mapper.order; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto; +import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 自动退款任务 Mapper,任务执行通过主键行锁串行化多实例竞争。 + */ +@Mapper +public interface HardAutoRefundTaskMapper extends PjcloudBaseMapper { + + /** + * 按主键锁定任务行,供状态复查、尝试计数和最终状态更新使用。 + * + * @param id 任务主键 + * @return 已锁定任务;不存在或已逻辑删除时返回 {@code null} + */ + default HardAutoRefundTaskEntity selectByIdForUpdate(Long id) { + return selectOne(new LambdaQueryWrapper() + .eq(HardAutoRefundTaskEntity::getId, id) + .last("FOR UPDATE")); + } + + /** + * 按管理筛选和服务端商户数据范围分页查询任务。 + * + * @param page 分页参数 + * @param dto 筛选条件,mchIds 由服务端注入 + * @return 自动退款任务分页 + */ + default Page pageTasks(Page page, HardAutoRefundTaskPageDto dto) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(dto.getId() != null, HardAutoRefundTaskEntity::getId, dto.getId()) + .eq(dto.getAfterSaleId() != null, HardAutoRefundTaskEntity::getAfterSaleId, dto.getAfterSaleId()) + .eq(dto.getOrderId() != null, HardAutoRefundTaskEntity::getOrderId, dto.getOrderId()) + .eq(dto.getMchId() != null, HardAutoRefundTaskEntity::getMchId, dto.getMchId()) + .eq(dto.getStatus() != null && !dto.getStatus().isBlank(), HardAutoRefundTaskEntity::getStatus, dto.getStatus()) + .in(dto.getMchIds() != null && !dto.getMchIds().isEmpty(), HardAutoRefundTaskEntity::getMchId, dto.getMchIds()) + .between(dto.getCreateTimeList() != null && dto.getCreateTimeList().length == 2, + HardAutoRefundTaskEntity::getCreateTime, + dto.getCreateTimeList() == null ? null : dto.getCreateTimeList()[0], + dto.getCreateTimeList() == null ? null : dto.getCreateTimeList()[1]) + .orderByDesc(HardAutoRefundTaskEntity::getCreateTime) + .orderByDesc(HardAutoRefundTaskEntity::getId); + return selectPage(page, wrapper); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java index 1c4a02bc0..50431a38a 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java @@ -64,6 +64,14 @@ public interface HardAfterSaleService extends IService { void refund(Long afterSaleId, String prefixStr); + /** + * 由自动退款任务在已持有订单锁的事务中完成钱包退款、售后状态、库存和订单聚合更新。 + * + * @param afterSaleId 自动退款任务绑定的售后单 ID + * @param autoRefundConfigId 下单快照中的自动退款配置 ID,Wallet 据此执行最终开关行锁校验 + */ + void completeAutoRefund(Long afterSaleId, Long autoRefundConfigId); + /** * 同意售后 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java index 254b935ce..4d5f3bbc0 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java @@ -5,6 +5,7 @@ import com.bsd.pjcloud.hard.api.vo.HardApptDateOptionsVo; import com.bsd.pjcloud.hard.api.vo.HardApptTimeCascadeVo; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; /** @@ -30,6 +31,16 @@ public interface HardApptReservationService { ReservationValidation validateReservation(Long mchId, Long apptId, Long apptRuleId, String hourType, LocalDate apptDate, String legacyApptTimeType); - record ReservationValidation(HardApptEntity appt, String hourType, LocalDate apptDate, Long apptRuleId) { + /** + * 服务端校验通过的预约上下文,周期批次截止时间可直接作为自动退款订单快照,后续不再重算规则。 + * + * @param appt 动态预约配置;周期模式为空 + * @param hourType 标准化餐段编码 + * @param apptDate 实际用餐日期 + * @param apptRuleId 周期预约规则 ID;动态模式为空 + * @param eligibilityDeadline 当前已校验预约窗口的截止时间,使用服务器现有预约时间口径 + */ + record ReservationValidation(HardApptEntity appt, String hourType, LocalDate apptDate, Long apptRuleId, + LocalDateTime eligibilityDeadline) { } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java new file mode 100644 index 000000000..c88ac2c1b --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java @@ -0,0 +1,110 @@ +package com.bsd.pjcloud.hard.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum; +import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto; +import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; +import com.bsd.pjcloud.hard.api.vo.order.HardAutoRefundTaskVo; +import com.bsd.pjcloud.hard.api.vo.order.app.HardAppAutoRefundTaskVo; + +import java.util.List; + +/** + * 自动退款任务状态机服务。 + */ +public interface HardAutoRefundTaskService extends IService { + + /** + * 在售后创建事务中根据订单快照、截止时间和当前配置尝试创建唯一任务。 + * + * @param order 已锁定的原订单 + * @param afterSale 新建售后单 + * @return 已创建任务;不符合免审条件或配置查询失败时返回 {@code null} + */ + HardAutoRefundTaskEntity tryCreateTask(HardOrderEntity order, HardAfterSaleEntity afterSale); + + /** + * 注册事务提交后的同步首次执行;回调内部吞掉资金异常并持久化重试状态。 + * + * @param taskId 新建任务主键 + */ + void executeFirstAfterCommit(Long taskId); + + /** + * 在订单 Redis 锁和任务行锁保护下执行一次自动或人工尝试。 + * + * @param taskId 任务主键 + * @param triggerType 触发来源 + * @param operatorUserId 人工重试用户 ID;自动触发为空 + */ + void execute(Long taskId, HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId); + + /** + * 分页查询管理端任务并补充用户、商户和售后金额。 + * + * @param page 分页参数 + * @param dto 服务端已注入商户数据范围的筛选条件 + * @return 任务分页 + */ + Page pageTasks(Page page, HardAutoRefundTaskPageDto dto); + + /** + * 查询任务的只读尝试日志,按尝试序号升序返回。 + * + * @param taskId 任务主键 + * @return 尝试日志列表 + */ + List listAttempts(Long taskId); + + /** + * 对重试耗尽任务执行一次额外技术重试,失败后仍保持耗尽状态。 + * + * @param taskId 任务主键 + * @param operatorUserId 当前管理用户 ID + */ + void manualRetry(Long taskId, Long operatorUserId); + + /** + * 按售后单查询自动退款任务。 + * + * @param afterSaleId 售后单 ID + * @return 自动退款任务;人工售后返回 {@code null} + */ + HardAutoRefundTaskEntity getByAfterSaleId(Long afterSaleId); + + /** + * 判断售后单是否已由自动任务占用,存在任意状态任务都禁止取消和商户审核。 + * + * @param afterSaleId 售后单 ID + * @return 存在任务时返回 {@code true} + */ + boolean existsByAfterSaleId(Long afterSaleId); + + /** + * 判断订单当前是否仍满足快照、闭区间截止和实时开关条件;配置异常按不满足处理。 + * + * @param order 原订单 + * @return 当前提交是否可进入自动退款 + */ + boolean isEligibleNow(HardOrderEntity order); + + /** + * 返回 App 所需的只读任务进度,不暴露内部错误详情。 + * + * @param afterSaleId 售后单 ID + * @return App 任务状态;人工售后返回 {@code null} + */ + HardAppAutoRefundTaskVo getAppTaskStatus(Long afterSaleId); + + /** + * 调度器无租户上下文时跨租户选取本轮最多指定数量的可执行或暂停任务。 + * + * @param limit 最大扫描数量 + * @return 待复查任务列表 + */ + List listScanCandidates(int limit); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java index 8ecf58746..3c1e3be61 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java @@ -357,7 +357,7 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic if (!availability.available()) { throw new RuntimeException(availability.unavailableReason()); } - return new ReservationValidation(appt, appt.getHourType(), targetDate, null); + return new ReservationValidation(appt, appt.getHourType(), targetDate, null, availability.endDateTime()); } private ReservationValidation validateCycleReservation(Long mchId, Long apptRuleId, String hourType, @@ -377,7 +377,7 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic if (!match.availability().available()) { throw new RuntimeException(match.availability().unavailableReason()); } - return new ReservationValidation(null, hourType, apptDate, selected.getId()); + return new ReservationValidation(null, hourType, apptDate, selected.getId(), match.availability().endDateTime()); } private CycleMatch matchCycleRule(HardApptCycleRuleVo rule, String hourType, diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java index a680a5128..bf69eb2a7 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java @@ -50,7 +50,9 @@ import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMicropayDto; import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdRemoterRefundDto; import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdMiniWechatRefundDto; import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeRefundDto; +import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService; import com.bsd.pjcloud.wallet.api.feign.RemoteWalletRechargeService; +import com.bsd.pjcloud.wallet.api.vo.WalletManageVo; import com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeVo; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -99,10 +101,15 @@ public class HardAfterSaleServiceImpl extends ServiceImpl orderList = hardOrderMapper.list(hardOrderDto); HardUtil.convertOrderList(list.getRecords(), CollectionUtils.convertMap(orderList, HardOrderEntity::getId)); } + fillAutoRefundTaskStatus(list.getRecords()); } return list; } @@ -159,6 +167,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl 0 || afterSales.stream() .anyMatch(item -> ObjectUtil.equals(item.getStatus(), HardSaleStatusEnum.APPLY.getStatus()))); HardAfterSaleEntity latest = afterSales.stream().max(Comparator.comparing(HardAfterSaleEntity::getId)).orElse(null); + HardAutoRefundTaskEntity latestTask = latest == null ? null : hardAutoRefundTaskService.getByAfterSaleId(latest.getId()); if (latest != null) { detail.setLatestAfterSaleId(latest.getId()); detail.setLatestAfterSaleStatus(latest.getStatus()); @@ -282,6 +297,13 @@ public class HardAfterSaleServiceImpl extends ServiceImpl products = allocations.values().stream().map(allocation -> { HardAppRefundProductVo product = new HardAppRefundProductVo(); @@ -306,6 +328,31 @@ public class HardAfterSaleServiceImpl extends ServiceImpl walletResult = walletService.getWalletById(order.getWalletId()); + WalletManageVo wallet = walletResult == null || !walletResult.isOk() ? null : walletResult.getData(); + return wallet != null && Objects.equals(wallet.getUserId(), order.getUserId()); + } catch (Exception exception) { + // 查询失败时回退为“原路退款”,避免把改造前绑定代订人钱包的历史订单错误提示为用餐人饭卡。 + log.warn("|{}|核对订单退款钱包所有人失败,orderId:|{}|,walletId:|{}|", TAG, + order.getId(), order.getWalletId(), exception); + return false; + } + } + /** * 售后读取沿用订单查看权限,避免代订订单通过售后接口越权暴露。 */ @@ -394,6 +441,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl walletRefundResult = walletRechargeService.refund(hardRefundDto); - if (walletRefundResult.getCode() != 0 || ObjectUtil.isEmpty(walletRefundResult.getData())) { + if (walletRefundResult == null || !walletRefundResult.isOk() || ObjectUtil.isEmpty(walletRefundResult.getData())) { throw new RuntimeException("钱包退款错误"); } WalletRechargeVo walletRefund = walletRefundResult.getData(); @@ -548,6 +610,55 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId); + if (ObjectUtil.isEmpty(refundCars)) { + hardOrderService.fastenerInventory(order, YesNoEnum.NO); + } else { + hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO); + } recomputeOrderRefundState(order.getId()); } @@ -778,6 +889,35 @@ public class HardAfterSaleServiceImpl extends ServiceImpl records) { + List afterSaleIds = records.stream().map(HardAfterSaleVo::getId).filter(Objects::nonNull).distinct().toList(); + if (afterSaleIds.isEmpty()) { + return; + } + Map taskStatusMap = hardAutoRefundTaskService.list( + new LambdaQueryWrapper() + .in(HardAutoRefundTaskEntity::getAfterSaleId, afterSaleIds)) + .stream().collect(Collectors.toMap(HardAutoRefundTaskEntity::getAfterSaleId, + HardAutoRefundTaskEntity::getStatus, (first, second) -> first)); + records.forEach(item -> item.setAutoRefundTaskStatus(taskStatusMap.get(item.getId()))); + } + + /** + * 自动退款任务一经创建即永久占用该售后单,旧 App 和旧管理端也不得取消、同意或拒绝。 + * + * @param afterSaleId 待执行人工操作的售后单 ID + */ + private void ensureNoAutoRefundTask(Long afterSaleId) { + if (hardAutoRefundTaskService.existsByAfterSaleId(afterSaleId)) { + throw new RuntimeException("自动退款处理中,当前售后单不允许人工取消或审核"); + } + } + /** * 读取订单购物车快照,退款金额和商品展示均不得回查可能已改价或下架的商品主数据。 * @@ -1191,9 +1331,16 @@ public class HardAfterSaleServiceImpl extends ServiceImpl + implements HardAutoRefundTaskService { + + /** + * 自动退款日志标签,只记录任务、订单和异常类型,不输出资金凭证或用户敏感信息。 + */ + private static final String TAG = "自动退款任务"; + + /** + * 与售后取消、同意和拒绝共用的订单退款锁键前缀。 + */ + private static final String REFUND_ORDER_LOCK_PREFIX = "hard:refund:order:"; + + /** + * 单次自动退款订单锁最长持有时间,单位毫秒。 + */ + private static final long REFUND_ORDER_LOCK_EXPIRE_MILLIS = 60_000L; + + /** + * 自动退款获取订单锁的最大等待时间,单位毫秒。 + */ + private static final long REFUND_ORDER_LOCK_WAIT_MILLIS = 3_000L; + + /** + * 首次同步执行加七次自动重试的最大自动尝试次数。 + */ + private static final int MAX_AUTO_ATTEMPTS = 8; + + /** + * 第 1 至第 7 次自动失败后的固定重试间隔,单位分钟。 + */ + private static final long[] RETRY_DELAY_MINUTES = {1L, 5L, 15L, 30L, 60L, 180L, 360L}; + + /** + * 配置服务异常后再次复查的等待时间,单位秒,不消耗自动尝试额度。 + */ + private static final long CONFIG_RECHECK_DELAY_SECONDS = 30L; + + /** + * 任务和尝试日志错误字段允许保存的最大字符数。 + */ + private static final int MAX_ERROR_LENGTH = 2000; + + /** + * 首期支持的预约订单类型编码。 + */ + private static final String SUPPORTED_ORDER_TYPE = "2"; + + /** + * 尝试日志 Mapper,用于保存每次自动、人工和开关跳过记录。 + */ + private final HardAutoRefundAttemptLogMapper attemptLogMapper; + + /** + * 售后 Mapper,用于管理分页补充金额和状态。 + */ + private final HardAfterSaleMapper afterSaleMapper; + + /** + * 售后服务延迟提供器,用于显式打破售后申请创建任务与任务完成售后的循环依赖。 + */ + private final ObjectProvider hardAfterSaleServiceProvider; + + /** + * Wallet 自动退款配置远程服务,用于受理和每次尝试前复核当前开关。 + */ + private final RemoteMchAutoRefundConfigService remoteConfigService; + + /** + * Wallet 退款远程服务,用于识别资金已到账但 Hardware 本地事务未提交的一致性恢复场景。 + */ + private final RemoteWalletRechargeService remoteWalletRechargeService; + + /** + * 商户远程服务,用于管理分页补充商户名称。 + */ + private final RemoteMchService remoteMchService; + + /** + * 用户远程服务,用于管理分页补充申请用户名称。 + */ + private final RemoteUserService remoteUserService; + + /** + * 订单级分布式锁模板,与既有售后操作使用相同 Redis 锁。 + */ + private final LockTemplate lockTemplate; + + /** + * 在售后申请事务内创建任务。配置服务异常、开关关闭或快照不完整时返回空并保留人工审核流程。 + * + * @param order 已锁定的原订单 + * @param afterSale 新建售后单 + * @return 已创建任务;不符合免审条件时返回 {@code null} + */ + @Override + public HardAutoRefundTaskEntity tryCreateTask(HardOrderEntity order, HardAfterSaleEntity afterSale) { + if (!hasValidSnapshot(order) || afterSale == null || afterSale.getId() == null + || LocalDateTime.now().isAfter(order.getAutoRefundDeadline())) { + return null; + } + MchAutoRefundConfigVo current; + try { + current = loadCurrentConfig(order.getMerchantId(), order.getOrderType()); + } catch (CurrentConfigLookupException exception) { + log.warn("|{}|受理时查询配置失败,orderId:{},exceptionType:{}", TAG, order.getId(), + exception.getCause() == null ? exception.getClass().getSimpleName() : exception.getCause().getClass().getSimpleName()); + return null; + } + if (!matchesSnapshot(order.getAutoRefundConfigId(), order.getAutoRefundRuleType(), current) + || !Objects.equals(current.getEnabled(), YesNoEnum.YES.getCode())) { + return null; + } + HardAutoRefundTaskEntity task = HardAutoRefundTaskEntity.builder() + .afterSaleId(afterSale.getId()) + .orderId(order.getId()) + .mchId(order.getMerchantId()) + .configId(order.getAutoRefundConfigId()) + .ruleType(order.getAutoRefundRuleType()) + .eligibilityDeadline(order.getAutoRefundDeadline()) + .status(HardAutoRefundTaskStatusEnum.PENDING.getStatus()) + .autoAttemptCount(0) + .totalAttemptCount(0) + .nextRetryTime(LocalDateTime.now()) + .build(); + if (baseMapper.insert(task) != 1) { + throw new RuntimeException("创建自动退款任务失败"); + } + return task; + } + + /** + * 事务提交成功后在当前请求线程同步执行首次退款,执行失败只更新任务,不改变创建接口返回值。 + * + * @param taskId 新建任务主键 + */ + @Override + public void executeFirstAfterCommit(Long taskId) { + Runnable action = () -> { + try { + execute(taskId, HardAutoRefundTriggerTypeEnum.INITIAL, null); + } catch (Exception exception) { + // 售后和任务已经提交,首次执行基础设施异常不能反向把创建接口改成失败;调度器会继续扫描待执行任务。 + log.error("|{}|首次同步执行异常,taskId:{},exceptionType:{}", TAG, taskId, + exception.getClass().getSimpleName(), exception); + } + }; + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + action.run(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** + * 售后和任务本地事务提交完成后同步尝试资金退款。 + */ + @Override + public void afterCommit() { + action.run(); + } + }); + } + + /** + * 使用订单 Redis 锁包裹尝试和失败状态落库,避免事务回滚释放行锁后被另一实例插入同一尝试序号。 + * + * @param taskId 任务主键 + * @param triggerType 触发来源 + * @param operatorUserId 人工重试用户 ID + */ + @Override + public void execute(Long taskId, HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId) { + HardAutoRefundTaskEntity current = getById(taskId); + if (current == null || Objects.equals(current.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) { + return; + } + LockInfo lock = lockTemplate.lock(REFUND_ORDER_LOCK_PREFIX + current.getOrderId(), REFUND_ORDER_LOCK_EXPIRE_MILLIS, + REFUND_ORDER_LOCK_WAIT_MILLIS); + if (lock == null) { + return; + } + try { + try { + getSelf().executeAttemptInTransaction(taskId, triggerType, operatorUserId); + } catch (CurrentConfigLookupException exception) { + if (!tryRecoverPersistedWalletRefund(taskId, triggerType, operatorUserId)) { + getSelf().pauseConfigUnavailableInTransaction(taskId, exception.getMessage()); + } + } catch (Exception exception) { + handleAttemptFailure(taskId, triggerType, operatorUserId, exception); + } + } finally { + lockTemplate.releaseLock(lock); + } + } + + /** + * 在单个 Hardware 事务中锁任务、复核配置、调用 Wallet 并原子完成售后和任务状态。 + * 资金调用异常会使本事务全部回滚,外层随后用独立事务写失败日志。 + * + * @param taskId 任务主键 + * @param triggerType 触发来源 + * @param operatorUserId 人工重试用户 ID + */ + @Transactional(rollbackFor = Exception.class) + public void executeAttemptInTransaction(Long taskId, HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId) { + HardAutoRefundTaskEntity task = baseMapper.selectByIdForUpdate(taskId); + if (!canExecute(task, triggerType)) { + return; + } + MchAutoRefundConfigVo current = loadCurrentConfig(task.getMchId(), SUPPORTED_ORDER_TYPE); + if (!matchesSnapshot(task.getConfigId(), task.getRuleType(), current)) { + if (!recoverPersistedWalletRefund(task, triggerType, operatorUserId)) { + pauseConfigUnavailable(task, "自动退款配置与订单快照不一致"); + } + return; + } + if (!Objects.equals(current.getEnabled(), YesNoEnum.YES.getCode())) { + if (!recoverPersistedWalletRefund(task, triggerType, operatorUserId)) { + pauseForDisabledSwitch(task, triggerType, operatorUserId); + } + return; + } + + int currentAutoAttempts = safeCount(task.getAutoAttemptCount()); + if (triggerType.isAutoAttempt() && currentAutoAttempts >= MAX_AUTO_ATTEMPTS) { + if (!recoverPersistedWalletRefund(task, triggerType, operatorUserId)) { + task.setStatus(HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus()); + task.setNextRetryTime(null); + baseMapper.updateById(task); + } + return; + } + completeAttempt(task, triggerType, operatorUserId, true); + } + + /** + * 记录一次成功完成的 Wallet 调用并原子推进售后和任务状态。恢复已到账记录时不再消耗自动尝试额度, + * 因为真正的资金调用已在此前回滚的 Hardware 事务中发生。 + * + * @param task 已锁定任务 + * @param triggerType 触发来源 + * @param operatorUserId 人工重试用户 ID + * @param countAutoAttempt 是否把本次调用计入八次自动资金尝试额度 + */ + private void completeAttempt(HardAutoRefundTaskEntity task, HardAutoRefundTriggerTypeEnum triggerType, + Long operatorUserId, boolean countAutoAttempt) { + int currentAutoAttempts = safeCount(task.getAutoAttemptCount()); + int attemptNo = safeCount(task.getTotalAttemptCount()) + 1; + LocalDateTime startedTime = LocalDateTime.now(); + HardAutoRefundAttemptLogEntity attempt = HardAutoRefundAttemptLogEntity.builder() + .taskId(task.getId()) + .afterSaleId(task.getAfterSaleId()) + .attemptNo(attemptNo) + .triggerType(triggerType.getType()) + .resultStatus(HardAutoRefundAttemptResultEnum.EXECUTING.getStatus()) + .refundCode(buildRefundCode(task)) + .operatorUserId(operatorUserId) + .startedTime(startedTime) + .build(); + if (attemptLogMapper.insert(attempt) != 1) { + throw new RuntimeException("创建自动退款尝试日志失败"); + } + task.setTotalAttemptCount(attemptNo); + task.setAutoAttemptCount(countAutoAttempt && triggerType.isAutoAttempt() ? currentAutoAttempts + 1 : currentAutoAttempts); + task.setLastAttemptTime(startedTime); + task.setLastError(null); + if (baseMapper.updateById(task) != 1) { + throw new RuntimeException("更新自动退款尝试次数失败"); + } + + hardAfterSaleServiceProvider.getObject().completeAutoRefund(task.getAfterSaleId(), task.getConfigId()); + + LocalDateTime finishedTime = LocalDateTime.now(); + attempt.setResultStatus(HardAutoRefundAttemptResultEnum.SUCCESS.getStatus()); + attempt.setFinishedTime(finishedTime); + if (attemptLogMapper.updateById(attempt) != 1) { + throw new RuntimeException("更新自动退款尝试日志失败"); + } + task.setStatus(HardAutoRefundTaskStatusEnum.SUCCESS.getStatus()); + task.setNextRetryTime(null); + task.setSuccessTime(finishedTime); + task.setLastError(null); + if (baseMapper.updateById(task) != 1) { + throw new RuntimeException("更新自动退款成功状态失败"); + } + } + + /** + * 实际尝试失败后再次读取当前开关;若失败由关闭竞态导致则转为暂停且不消耗自动额度。 + * + * @param taskId 任务主键 + * @param triggerType 触发来源 + * @param operatorUserId 人工重试用户 ID + * @param exception 本次执行异常 + */ + private void handleAttemptFailure(Long taskId, HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId, + Exception exception) { + HardAutoRefundTaskEntity task = getById(taskId); + if (task == null || Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) { + return; + } + // Wallet 可能已经提交资金事务而 Hardware 因超时或本地异常回滚,必须先恢复到账结果再判断是否暂停。 + if (tryRecoverPersistedWalletRefund(taskId, triggerType, operatorUserId)) { + return; + } + try { + MchAutoRefundConfigVo current = loadCurrentConfig(task.getMchId(), SUPPORTED_ORDER_TYPE); + if (!matchesSnapshot(task.getConfigId(), task.getRuleType(), current)) { + getSelf().pauseConfigUnavailableInTransaction(taskId, "自动退款配置与订单快照不一致"); + return; + } + if (!Objects.equals(current.getEnabled(), YesNoEnum.YES.getCode())) { + getSelf().pauseSwitchAfterFailureInTransaction(taskId, triggerType, operatorUserId); + return; + } + } catch (CurrentConfigLookupException configException) { + getSelf().pauseConfigUnavailableInTransaction(taskId, configException.getMessage()); + return; + } + getSelf().recordFailureInTransaction(taskId, triggerType, operatorUserId, safeError(exception)); + } + + /** + * 在当前订单 Redis 锁内探测 Wallet 已到账记录,并通过独立 Hardware 事务恢复本地状态。 + * 此方法直接调用事务代理,不递归进入 {@link #execute(Long, HardAutoRefundTriggerTypeEnum, Long)},避免重复获取同一订单锁。 + * + * @param taskId 任务主键 + * @param triggerType 触发来源 + * @param operatorUserId 人工重试用户 ID + * @return Wallet 已存在退款记录并完成本地恢复时返回 {@code true} + */ + private boolean tryRecoverPersistedWalletRefund(Long taskId, HardAutoRefundTriggerTypeEnum triggerType, + Long operatorUserId) { + HardAutoRefundTaskEntity task = getById(taskId); + if (!hasPersistedWalletRefund(task)) { + return false; + } + getSelf().recoverPersistedWalletRefundInTransaction(taskId, triggerType, operatorUserId); + return true; + } + + /** + * 在独立 Hardware 事务中锁定任务并恢复 Wallet 已到账结果,不重新获取订单 Redis 锁。 + * Wallet 的退款接口仍会按售后单 ID 严格校验钱包和金额后返回原记录。 + * + * @param taskId 任务主键 + * @param triggerType 触发来源 + * @param operatorUserId 人工重试用户 ID + */ + @Transactional(rollbackFor = Exception.class) + public void recoverPersistedWalletRefundInTransaction(Long taskId, HardAutoRefundTriggerTypeEnum triggerType, + Long operatorUserId) { + HardAutoRefundTaskEntity task = baseMapper.selectByIdForUpdate(taskId); + if (task == null || Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) { + return; + } + completeAttempt(task, triggerType, operatorUserId, false); + } + + /** + * 在当前 Hardware 事务中恢复已持久化的钱包退款。调用方已持有任务行锁,恢复过程不消耗自动额度。 + * + * @param task 已锁定任务 + * @param triggerType 触发来源 + * @param operatorUserId 人工重试用户 ID + * @return Wallet 已存在退款记录并完成本地恢复时返回 {@code true} + */ + private boolean recoverPersistedWalletRefund(HardAutoRefundTaskEntity task, + HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId) { + if (!hasPersistedWalletRefund(task)) { + return false; + } + completeAttempt(task, triggerType, operatorUserId, false); + return true; + } + + /** + * 查询 Wallet 是否已按当前售后单持久化退款记录。查询异常按未确认到账处理并保留任务,禁止据此完成售后。 + * + * @param task 自动退款任务 + * @return Wallet 明确返回退款记录时返回 {@code true} + */ + private boolean hasPersistedWalletRefund(HardAutoRefundTaskEntity task) { + if (task == null || task.getAfterSaleId() == null) { + return false; + } + try { + R result = remoteWalletRechargeService.getRefundByAfterSaleId(task.getAfterSaleId()); + if (result == null || !result.isOk()) { + log.warn("|{}|查询钱包退款记录失败,taskId:{},afterSaleId:{}", TAG, task.getId(), task.getAfterSaleId()); + return false; + } + return result.getData() != null; + } catch (Exception exception) { + log.warn("|{}|查询钱包退款记录异常,taskId:{},afterSaleId:{},exceptionType:{}", TAG, task.getId(), + task.getAfterSaleId(), exception.getClass().getSimpleName()); + return false; + } + } + + /** + * 独立事务记录一次真实资金尝试失败,并根据自动次数计算固定重试时间或耗尽状态。 + * + * @param taskId 任务主键 + * @param triggerType 触发来源 + * @param operatorUserId 人工重试用户 ID + * @param errorMessage 已截断的非敏感错误信息 + */ + @Transactional(rollbackFor = Exception.class) + public void recordFailureInTransaction(Long taskId, HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId, + String errorMessage) { + HardAutoRefundTaskEntity task = baseMapper.selectByIdForUpdate(taskId); + if (task == null || Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) { + return; + } + int attemptNo = safeCount(task.getTotalAttemptCount()) + 1; + int autoAttempts = safeCount(task.getAutoAttemptCount()) + (triggerType.isAutoAttempt() ? 1 : 0); + LocalDateTime now = LocalDateTime.now(); + HardAutoRefundAttemptLogEntity attempt = HardAutoRefundAttemptLogEntity.builder() + .taskId(task.getId()) + .afterSaleId(task.getAfterSaleId()) + .attemptNo(attemptNo) + .triggerType(triggerType.getType()) + .resultStatus(HardAutoRefundAttemptResultEnum.FAILED.getStatus()) + .refundCode(buildRefundCode(task)) + .operatorUserId(operatorUserId) + .startedTime(now) + .finishedTime(now) + .errorMessage(errorMessage) + .build(); + attemptLogMapper.insert(attempt); + + task.setTotalAttemptCount(attemptNo); + task.setAutoAttemptCount(autoAttempts); + task.setLastAttemptTime(now); + task.setLastError(errorMessage); + if (triggerType == HardAutoRefundTriggerTypeEnum.MANUAL || autoAttempts >= MAX_AUTO_ATTEMPTS) { + task.setStatus(HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus()); + task.setNextRetryTime(null); + } else { + task.setStatus(HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus()); + task.setNextRetryTime(now.plusMinutes(RETRY_DELAY_MINUTES[autoAttempts - 1])); + } + baseMapper.updateById(task); + log.warn("|{}|退款尝试失败,taskId:{},orderId:{},attemptNo:{},status:{}", TAG, task.getId(), task.getOrderId(), + attemptNo, task.getStatus()); + } + + /** + * 配置服务异常或配置快照不一致时暂停任务,不增加自动和总尝试次数。 + * + * @param taskId 任务主键 + * @param reason 暂停原因 + */ + @Transactional(rollbackFor = Exception.class) + public void pauseConfigUnavailableInTransaction(Long taskId, String reason) { + HardAutoRefundTaskEntity task = baseMapper.selectByIdForUpdate(taskId); + if (task == null || Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) { + return; + } + pauseConfigUnavailable(task, reason); + } + + /** + * 已发起调用后发现开关关闭时写一条跳过日志并暂停,不增加自动尝试次数。 + * + * @param taskId 任务主键 + * @param triggerType 触发来源 + * @param operatorUserId 人工重试用户 ID + */ + @Transactional(rollbackFor = Exception.class) + public void pauseSwitchAfterFailureInTransaction(Long taskId, HardAutoRefundTriggerTypeEnum triggerType, + Long operatorUserId) { + HardAutoRefundTaskEntity task = baseMapper.selectByIdForUpdate(taskId); + if (task == null || Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) { + return; + } + pauseForDisabledSwitch(task, triggerType, operatorUserId); + } + + /** + * 更新配置不可用暂停状态并设置下一轮复查时间。 + * + * @param task 已锁定任务 + * @param reason 暂停原因 + */ + private void pauseConfigUnavailable(HardAutoRefundTaskEntity task, String reason) { + task.setStatus(HardAutoRefundTaskStatusEnum.CONFIG_UNAVAILABLE.getStatus()); + task.setNextRetryTime(LocalDateTime.now().plusSeconds(CONFIG_RECHECK_DELAY_SECONDS)); + task.setLastError(truncate(reason)); + baseMapper.updateById(task); + } + + /** + * 首次遇到关闭开关时记录一次跳过审计,持续暂停扫描不会重复增加日志。 + * + * @param task 已锁定任务 + * @param triggerType 触发来源 + * @param operatorUserId 人工重试用户 ID + */ + private void pauseForDisabledSwitch(HardAutoRefundTaskEntity task, HardAutoRefundTriggerTypeEnum triggerType, + Long operatorUserId) { + if (!Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus())) { + int attemptNo = safeCount(task.getTotalAttemptCount()) + 1; + LocalDateTime now = LocalDateTime.now(); + HardAutoRefundAttemptLogEntity attempt = HardAutoRefundAttemptLogEntity.builder() + .taskId(task.getId()) + .afterSaleId(task.getAfterSaleId()) + .attemptNo(attemptNo) + .triggerType(triggerType.getType()) + .resultStatus(HardAutoRefundAttemptResultEnum.SWITCH_DISABLED.getStatus()) + .refundCode(buildRefundCode(task)) + .operatorUserId(operatorUserId) + .startedTime(now) + .finishedTime(now) + .build(); + attemptLogMapper.insert(attempt); + task.setTotalAttemptCount(attemptNo); + task.setLastAttemptTime(now); + } + task.setStatus(HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus()); + task.setNextRetryTime(null); + task.setLastError(null); + baseMapper.updateById(task); + } + + /** + * 判断当前任务状态和触发来源是否允许进入资金尝试。 + * + * @param task 已锁定任务 + * @param triggerType 触发来源 + * @return 允许执行时返回 {@code true} + */ + private boolean canExecute(HardAutoRefundTaskEntity task, HardAutoRefundTriggerTypeEnum triggerType) { + if (task == null || Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) { + return false; + } + if (triggerType == HardAutoRefundTriggerTypeEnum.MANUAL) { + return Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus()); + } + if (Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus())) { + return false; + } + if (Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus()) + && task.getNextRetryTime() != null && task.getNextRetryTime().isAfter(LocalDateTime.now())) { + return false; + } + return true; + } + + /** + * 查询当前配置并统一校验 Feign 业务响应,异常由调用方区分受理降级或任务暂停。 + * + * @param mchId 商户 ID + * @param orderType 硬件订单类型编码 + * @return 当前配置;未配置时返回 {@code null} + */ + private MchAutoRefundConfigVo loadCurrentConfig(Long mchId, String orderType) { + try { + R result = remoteConfigService.getExact(mchId, orderType); + if (result == null || !result.isOk()) { + throw new CurrentConfigLookupException(result == null ? "自动退款配置服务无响应" + : StrUtil.blankToDefault(result.getMsg(), "自动退款配置查询失败")); + } + return result.getData(); + } catch (CurrentConfigLookupException exception) { + throw exception; + } catch (Exception exception) { + throw new CurrentConfigLookupException("自动退款配置查询失败", exception); + } + } + + /** + * 校验配置主键和规则仍与订单快照一致;开关单独判断以支持关闭暂停。 + * + * @param configId 快照配置主键 + * @param ruleType 快照规则名称 + * @param current 当前 Wallet 配置 + * @return 配置身份和规则一致时返回 {@code true} + */ + private boolean matchesSnapshot(Long configId, String ruleType, MchAutoRefundConfigVo current) { + return current != null && Objects.equals(current.getId(), configId) + && Objects.equals(current.getRuleType(), ruleType) + && Objects.equals(current.getOrderType(), SUPPORTED_ORDER_TYPE); + } + + /** + * 校验订单是否具备每日菜谱自动退款的完整快照和固定支付协议。 + * + * @param order 原订单 + * @return 快照完整且订单类型、支付方式符合首期范围时返回 {@code true} + */ + private boolean hasValidSnapshot(HardOrderEntity order) { + return order != null && Objects.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType()) + && Objects.equals(order.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType()) + && order.getAutoRefundConfigId() != null + && order.getAutoRefundRuleType() != null + && order.getAutoRefundDeadline() != null; + } + + /** + * 分页查询任务并尽力补充关联展示数据;远程展示信息异常不会影响技术人员查看任务本体。 + * + * @param page 分页参数 + * @param dto 服务端已注入商户数据范围的筛选条件 + * @return 任务分页 + */ + @Override + public Page pageTasks(Page page, HardAutoRefundTaskPageDto dto) { + Page entityPage = baseMapper.pageTasks(page, dto); + List records = BeanUtils.toBean(entityPage.getRecords(), HardAutoRefundTaskVo.class); + fillAfterSaleData(records); + fillUserData(records); + fillMerchantData(records); + Page result = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); + result.setRecords(records); + return result; + } + + /** + * 批量补充售后申请用户、核定金额和当前状态。 + * + * @param records 当前页任务响应 + */ + private void fillAfterSaleData(List records) { + List ids = records.stream().map(HardAutoRefundTaskVo::getAfterSaleId).filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) { + return; + } + Map map = afterSaleMapper.selectBatchIds(ids).stream() + .collect(Collectors.toMap(HardAfterSaleEntity::getId, Function.identity(), (first, second) -> first)); + records.forEach(item -> { + HardAfterSaleEntity afterSale = map.get(item.getAfterSaleId()); + if (afterSale != null) { + item.setUserId(afterSale.getUserId()); + item.setRefundPrice(afterSale.getRefundPrice()); + item.setAfterSaleStatus(afterSale.getStatus()); + } + }); + } + + /** + * 批量补充用户名称,远程异常只记录警告并保留用户 ID。 + * + * @param records 当前页任务响应 + */ + private void fillUserData(List records) { + List userIds = records.stream().map(HardAutoRefundTaskVo::getUserId).filter(Objects::nonNull).distinct().toList(); + if (userIds.isEmpty()) { + return; + } + try { + R> response = remoteUserService.getUserById(new ArrayList<>(userIds)); + if (response == null || !response.isOk() || response.getData() == null) { + return; + } + Map map = response.getData().stream().filter(Objects::nonNull) + .collect(Collectors.toMap(SysUser::getUserId, Function.identity(), (first, second) -> first)); + records.forEach(item -> { + SysUser user = map.get(item.getUserId()); + if (user != null) { + item.setUserName(StrUtil.blankToDefault(user.getName(), + StrUtil.blankToDefault(user.getNickname(), user.getUsername()))); + } + }); + } catch (Exception exception) { + log.warn("|{}|补充用户名称失败,exceptionType:{}", TAG, exception.getClass().getSimpleName()); + } + } + + /** + * 批量补充商户名称,远程异常只记录警告并保留商户 ID。 + * + * @param records 当前页任务响应 + */ + private void fillMerchantData(List records) { + List mchIds = records.stream().map(HardAutoRefundTaskVo::getMchId).filter(Objects::nonNull).distinct().toList(); + if (mchIds.isEmpty()) { + return; + } + try { + R> response = remoteMchService.getByIds(mchIds); + if (response == null || !response.isOk() || response.getData() == null) { + return; + } + Map map = response.getData().stream().filter(Objects::nonNull) + .collect(Collectors.toMap(MchManageVo::getId, Function.identity(), (first, second) -> first)); + records.forEach(item -> { + MchManageVo merchant = map.get(item.getMchId()); + if (merchant != null) { + item.setMchName(merchant.getName()); + } + }); + } catch (Exception exception) { + log.warn("|{}|补充商户名称失败,exceptionType:{}", TAG, exception.getClass().getSimpleName()); + } + } + + /** + * 查询只读尝试日志。 + * + * @param taskId 任务主键 + * @return 按尝试序号升序的日志列表 + */ + @Override + public List listAttempts(Long taskId) { + return attemptLogMapper.selectList(new LambdaQueryWrapper() + .eq(HardAutoRefundAttemptLogEntity::getTaskId, taskId) + .orderByAsc(HardAutoRefundAttemptLogEntity::getAttemptNo)); + } + + /** + * 仅允许重试耗尽任务进入人工额外尝试,重复点击仍由任务行锁复查状态。 + * + * @param taskId 任务主键 + * @param operatorUserId 当前管理用户 ID + */ + @Override + public void manualRetry(Long taskId, Long operatorUserId) { + HardAutoRefundTaskEntity task = getById(taskId); + if (task == null) { + throw new RuntimeException("自动退款任务不存在"); + } + if (!Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus())) { + throw new RuntimeException("仅重试耗尽任务允许人工重新触发"); + } + execute(taskId, HardAutoRefundTriggerTypeEnum.MANUAL, operatorUserId); + } + + /** + * 按售后唯一键查询任务。 + * + * @param afterSaleId 售后单 ID + * @return 匹配任务;人工售后返回 {@code null} + */ + @Override + public HardAutoRefundTaskEntity getByAfterSaleId(Long afterSaleId) { + if (afterSaleId == null) { + return null; + } + return baseMapper.selectOne(new LambdaQueryWrapper() + .eq(HardAutoRefundTaskEntity::getAfterSaleId, afterSaleId)); + } + + /** + * 售后单存在任意状态自动任务时均视为自动流程占用。 + * + * @param afterSaleId 售后单 ID + * @return 存在任务时返回 {@code true} + */ + @Override + public boolean existsByAfterSaleId(Long afterSaleId) { + return getByAfterSaleId(afterSaleId) != null; + } + + /** + * 按订单快照、闭区间截止和实时配置判断当前申请是否可进入免审流程。 + * + * @param order 原订单 + * @return 当前满足免审条件时返回 {@code true} + */ + @Override + public boolean isEligibleNow(HardOrderEntity order) { + if (!hasValidSnapshot(order) || LocalDateTime.now().isAfter(order.getAutoRefundDeadline())) { + return false; + } + try { + MchAutoRefundConfigVo current = loadCurrentConfig(order.getMerchantId(), order.getOrderType()); + return matchesSnapshot(order.getAutoRefundConfigId(), order.getAutoRefundRuleType(), current) + && Objects.equals(current.getEnabled(), YesNoEnum.YES.getCode()); + } catch (CurrentConfigLookupException exception) { + return false; + } + } + + /** + * 转换 App 只读任务状态,不暴露 last_error。 + * + * @param afterSaleId 售后单 ID + * @return App 任务状态;人工售后返回 {@code null} + */ + @Override + public HardAppAutoRefundTaskVo getAppTaskStatus(Long afterSaleId) { + HardAutoRefundTaskEntity task = getByAfterSaleId(afterSaleId); + if (task == null) { + return null; + } + HardAppAutoRefundTaskVo result = new HardAppAutoRefundTaskVo(); + result.setAfterSaleId(task.getAfterSaleId()); + result.setStatus(task.getStatus()); + result.setAutoAttemptCount(task.getAutoAttemptCount()); + result.setNextRetryTime(task.getNextRetryTime()); + result.setSuccessTime(task.getSuccessTime()); + return result; + } + + /** + * 先选择到期的新建/重试任务,再用剩余额度选择暂停任务,保证大量关闭商户不会饿死资金重试。 + * + * @param limit 最大扫描数量 + * @return 本轮待复查任务列表 + */ + @Override + public List listScanCandidates(int limit) { + if (limit <= 0) { + return List.of(); + } + LocalDateTime now = LocalDateTime.now(); + List result = new ArrayList<>(baseMapper.selectList( + new LambdaQueryWrapper() + .and(wrapper -> wrapper.eq(HardAutoRefundTaskEntity::getStatus, + HardAutoRefundTaskStatusEnum.PENDING.getStatus()) + .or(nested -> nested.eq(HardAutoRefundTaskEntity::getStatus, + HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus()) + .le(HardAutoRefundTaskEntity::getNextRetryTime, now))) + .orderByAsc(HardAutoRefundTaskEntity::getNextRetryTime) + .orderByAsc(HardAutoRefundTaskEntity::getId) + .last("limit " + limit))); + int remaining = limit - result.size(); + if (remaining <= 0) { + return result; + } + result.addAll(baseMapper.selectList(new LambdaQueryWrapper() + .in(HardAutoRefundTaskEntity::getStatus, List.of( + HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus(), + HardAutoRefundTaskStatusEnum.CONFIG_UNAVAILABLE.getStatus())) + .and(wrapper -> wrapper.isNull(HardAutoRefundTaskEntity::getNextRetryTime) + .or().le(HardAutoRefundTaskEntity::getNextRetryTime, now)) + .orderByAsc(HardAutoRefundTaskEntity::getUpdateTime) + .orderByAsc(HardAutoRefundTaskEntity::getId) + .last("limit " + remaining))); + return result; + } + + /** + * 构造 Wallet 和尝试日志共同使用的稳定退款业务号。 + * + * @param task 自动退款任务 + * @return hard_{orderId}_{afterSaleId} + */ + private String buildRefundCode(HardAutoRefundTaskEntity task) { + return "hard_" + task.getOrderId() + "_" + task.getAfterSaleId(); + } + + /** + * 规范化可空计数,历史空值按零处理。 + * + * @param value 可空计数 + * @return 非负计数 + */ + private int safeCount(Integer value) { + return value == null ? 0 : Math.max(0, value); + } + + /** + * 提取非敏感异常消息并限制数据库字段长度。 + * + * @param exception 本次尝试异常 + * @return 最多 2000 字符的诊断信息 + */ + private String safeError(Exception exception) { + String message = StrUtil.blankToDefault(exception.getMessage(), exception.getClass().getSimpleName()); + return truncate(message); + } + + /** + * 截断任务诊断信息,避免数据库字段溢出。 + * + * @param value 原始诊断信息 + * @return 最多 2000 字符的文本 + */ + private String truncate(String value) { + if (value == null || value.length() <= MAX_ERROR_LENGTH) { + return value; + } + return value.substring(0, MAX_ERROR_LENGTH); + } + + /** + * 获取当前 Spring 代理,保证类内调用的事务方法经过事务拦截器。 + * + * @return 当前任务服务代理 + */ + private HardAutoRefundTaskServiceImpl getSelf() { + return SpringUtil.getBean(getClass()); + } + + /** + * 配置服务调用或业务响应失败的内部异常,用于与真实资金尝试失败区分且不消耗重试额度。 + */ + private static final class CurrentConfigLookupException extends RuntimeException { + + /** + * 使用业务提示构造配置查询异常。 + * + * @param message 不包含敏感信息的失败原因 + */ + private CurrentConfigLookupException(String message) { + super(message); + } + + /** + * 使用业务提示和原异常构造配置查询异常。 + * + * @param message 不包含敏感信息的失败原因 + * @param cause 原始远程异常 + */ + private CurrentConfigLookupException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java index 338bfa58f..2c67b6676 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java @@ -113,6 +113,7 @@ import com.bsd.pjcloud.wallet.api.constant.WalletCodeBizEnum; import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum; import com.bsd.pjcloud.wallet.api.constant.act.ActCancelStatusEnum; import com.bsd.pjcloud.wallet.api.constant.mch.WalletMchRulePriceTypeEnum; +import com.bsd.pjcloud.wallet.api.constant.mch.MchAutoRefundRuleTypeEnum; import com.bsd.pjcloud.wallet.api.constant.mch.WalletMchSupType; import com.bsd.pjcloud.wallet.api.constant.mch.WalletMchTableReservationMode; import com.bsd.pjcloud.wallet.api.dto.MchManageDto; @@ -132,6 +133,7 @@ import com.bsd.pjcloud.wallet.api.vo.act.ActCancelItemVo; import com.bsd.pjcloud.wallet.api.vo.act.ActComItemVo; import com.bsd.pjcloud.wallet.api.vo.mch.MchPickUpVo; import com.bsd.pjcloud.wallet.api.vo.mch.MchRuleJoinDtoVo; +import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -195,6 +197,9 @@ public class HardOrderServiceImpl extends ServiceImpl dinerUserIds = validateReserveDinerUserIds(payerUserId, dto.getDinerUserIds()); HardParentCarEntity sourceParentCar = hardCarService.getParentCarById(dto.getCarParentId()); if (sourceParentCar == null || !Objects.equals(sourceParentCar.getUserId(), payerUserId)) { @@ -1323,10 +1331,6 @@ public class HardOrderServiceImpl extends ServiceImpl new RuntimeException("用户钱包不存在")); - if (!Objects.equals(wallet.getUserId(), payerUserId)) { - throw new RuntimeException("钱包不属于当前付款人"); - } List dinerUsers = Optional.ofNullable(userService.getInnerUserById(dinerUserIds)).map(R::getData).orElseThrow(() -> new RuntimeException("查询用餐人失败")); Map dinerUserMap = dinerUsers.stream().collect(Collectors.toMap(SysUser::getUserId, Function.identity(), (first, second) -> first)); if (dinerUserMap.size() != dinerUserIds.size()) { @@ -1343,35 +1347,40 @@ public class HardOrderServiceImpl extends ServiceImpl dinerWalletMap = resolveReserveDinerWallets(dinerUserIds, dinerUserMap, singlePrice); Map inventorySnapshot = validateReserveBatchInventory(sourceParentCar.getMerchantId(), sourceCarList, dinerUserIds.size()); String subject = buildReserveOrderSubject(dto.getCarParentId(), sourceCarList); // 在所有远程预检完成后再占用餐段锁,缩短并发请求等待时间;锁仍覆盖查询、落单与事务提交。 validateSingleReservationOnlyPerMeal(merchant, dinerUserIds, reservation); List createdOrders = new ArrayList<>(); + String inventoryBizId = null; + boolean inventoryOperationAttempted = false; try { for (int index = 0; index < dinerUserIds.size(); index++) { Long dinerUserId = dinerUserIds.get(index); Long parentCarId = index == 0 ? sourceParentCar.getId() : cloneReserveCart(sourceParentCar, sourceCarList, payerUserId); - HardOrderEntity order = buildReserveOrder(dinerUserMap.get(dinerUserId), parentCarId, merchant, wallet, reservation, singlePrice, subject); + HardOrderEntity order = buildReserveOrder(dinerUserMap.get(dinerUserId), parentCarId, merchant, + dinerWalletMap.get(dinerUserId), reservation, singlePrice, subject); if (baseMapper.insert(order) != 1) { throw new RuntimeException("创建订餐订单失败"); } createdOrders.add(order); } // 一次性按用餐人数扣减汇总库存,避免逐单扣减中途失败产生临时部分占用。 - updateReserveBatchInventory(sourceCarList, dinerUserIds.size(), true, inventorySnapshot); + inventoryBizId = buildReserveInventoryBizId(createdOrders); + inventoryOperationAttempted = true; + updateReserveBatchInventory(sourceCarList, dinerUserIds.size(), true, inventorySnapshot, inventoryBizId); + registerReserveOrderRollbackCompensation(createdOrders, sourceCarList, dinerUserIds.size(), inventorySnapshot, inventoryBizId); + reduceReserveOrderBalances(createdOrders); for (HardOrderEntity order : createdOrders) { - reduceReserveOrderBalance(order); markReserveOrderPaid(order); } } catch (Exception exception) { // 库存接口以整批原子事务执行,但远程响应超时后调用方无法判断是否已经提交; // 因此无论扣减调用在哪一步失败,都按同一批订单号发起一次幂等恢复。 - compensateReserveOrderBatch(createdOrders, sourceCarList, dinerUserIds.size(), inventorySnapshot); + compensateReserveOrderBatch(createdOrders, sourceCarList, dinerUserIds.size(), inventorySnapshot, + inventoryOperationAttempted ? inventoryBizId : null); throw exception; } @@ -1405,11 +1414,11 @@ public class HardOrderServiceImpl extends ServiceImpl contexts = loadDailyMenuReserveGroupContexts(payerUserId, groups); Long merchantId = contexts.get(0).parentCar().getMerchantId(); MchManageVo merchant = validateDailyMenuReserveMerchant(merchantId); + MchAutoRefundConfigVo autoRefundConfig = loadDailyMenuAutoRefundConfig(merchantId); validateMerchantReserveDinerPermissions(payerUserId, dinerUserIds, merchant); validateDailyMenuCartProducts(merchantId, contexts); validateDailyMenuSingleProductLimit(merchant, contexts); - WalletManageEntity wallet = getDailyMenuPaymentWallet(payerUserId, dto.getWalletTypeId()); List dinerUsers = Optional.ofNullable(userService.getInnerUserById(dinerUserIds)) .map(R::getData) .orElseThrow(() -> new RuntimeException("查询用餐人失败")); @@ -1434,9 +1443,7 @@ public class HardOrderServiceImpl extends ServiceImpl dinerWalletMap = resolveReserveDinerWallets(dinerUserIds, dinerUserMap, singleDinerTotalPrice); // 固定按日期和餐段顺序获取冲突锁,避免两个批量请求以相反分组顺序互相等待。 contexts.stream() @@ -1454,19 +1461,19 @@ public class HardOrderServiceImpl extends ServiceImpl createdOrders) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { @@ -1506,6 +1513,42 @@ public class HardOrderServiceImpl extends ServiceImpl createdOrders, + List sourceCarList, + int dinerCount, + Map inventorySnapshot, + String inventoryBizId) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + throw new RuntimeException("订餐事务状态异常,请稍后重试"); + } + List rollbackOrders = List.copyOf(createdOrders); + List rollbackCars = List.copyOf(sourceCarList); + Map rollbackInventory = Map.copyOf(inventorySnapshot); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** + * 本地事务明确回滚时恢复库存并补偿已存在的远程钱包支付流水。 + * + * @param status Spring 事务完成状态 + */ + @Override + public void afterCompletion(int status) { + if (status == TransactionSynchronization.STATUS_ROLLED_BACK) { + compensateReserveOrderBatch(rollbackOrders, rollbackCars, dinerCount, rollbackInventory, inventoryBizId); + } + } + }); + } + /** * 规范化每日菜谱分组并拒绝重复父购物车,避免同一购物车在一个批次内被创建多次订单。 * @@ -1675,22 +1718,128 @@ public class HardOrderServiceImpl extends ServiceImpl result = remoteWalletService.getWalletByWalletTypeId(payerUserId, walletTypeId); - WalletManageEntity wallet = result == null || !result.isOk() ? null : result.getData(); - if (wallet == null) { - throw new RuntimeException(result == null ? "查询用户钱包无响应" : StrUtil.blankToDefault(result.getMsg(), "用户钱包不存在")); + private MchAutoRefundConfigVo loadDailyMenuAutoRefundConfig(Long merchantId) { + try { + R response = remoteMchAutoRefundConfigService.getExact(merchantId, + HardOrderTypeEnum.RESERVE.getType()); + if (response == null || !response.isOk() || response.getData() == null) { + return null; + } + MchAutoRefundConfigVo config = response.getData(); + if (!Objects.equals(config.getEnabled(), YesNoEnum.YES.getCode()) + || !Objects.equals(config.getMchId(), merchantId) + || !Objects.equals(config.getOrderType(), HardOrderTypeEnum.RESERVE.getType())) { + return null; + } + MchAutoRefundRuleTypeEnum rule = MchAutoRefundRuleTypeEnum.valueOfName(config.getRuleType()); + if (rule == null) { + return null; + } + if (rule == MchAutoRefundRuleTypeEnum.PAY_TIME_LIMIT + && (config.getPayTimeLimitHours() == null || config.getPayTimeLimitHours() < 1 + || config.getPayTimeLimitHours() > 168)) { + return null; + } + return config; + } catch (Exception exception) { + log.warn("|{}|查询每日菜谱自动退款配置失败,mchId:|{}|,exceptionType:|{}|", TAG, merchantId, + exception.getClass().getSimpleName()); + return null; } - if (!Objects.equals(wallet.getUserId(), payerUserId)) { - throw new RuntimeException("钱包不属于当前付款人"); + } + + /** + * 把下单时命中的配置身份、规则和可确定截止时间写入订单。周期规则缺少有效批次时整组快照留空。 + * + * @param order 尚未入库的每日菜谱订单 + * @param reservation 已校验预约上下文 + * @param config 本批次唯一配置查询结果;为空时不写快照 + */ + private void applyDailyMenuAutoRefundSnapshot(HardOrderEntity order, + HardApptReservationService.ReservationValidation reservation, + MchAutoRefundConfigVo config) { + if (config == null) { + return; } - return wallet; + MchAutoRefundRuleTypeEnum rule = MchAutoRefundRuleTypeEnum.valueOfName(config.getRuleType()); + if (rule == MchAutoRefundRuleTypeEnum.APPT_CYCLE_CLOSE + && (reservation.apptRuleId() == null || reservation.eligibilityDeadline() == null)) { + return; + } + order.setAutoRefundConfigId(config.getId()); + order.setAutoRefundRuleType(config.getRuleType()); + if (rule == MchAutoRefundRuleTypeEnum.APPT_CYCLE_CLOSE) { + order.setAutoRefundDeadline(reservation.eligibilityDeadline()); + } + } + + /** + * 批量解析实际用餐人的 EAT 饭卡并校验每人本批次应付金额。客户端不提交钱包 ID,避免代订人替换被监护人的扣款目标。 + * + * @param dinerUserIds 保持用户选择顺序的实际用餐人 ID + * @param dinerUserMap 服务端查询得到的实际用餐人资料 + * @param requiredPricePerDiner 每位用餐人本批次应付金额,单位为分 + * @return 按实际用餐人 ID 索引的本人 EAT 饭卡 + */ + private Map resolveReserveDinerWallets(List dinerUserIds, + Map dinerUserMap, + int requiredPricePerDiner) { + if (requiredPricePerDiner <= 0) { + throw new RuntimeException("订餐金额异常"); + } + R> result; + try { + result = remoteWalletService.getWalletsByUserIds(dinerUserIds, WalletTypeEnum.EAT); + } catch (Exception exception) { + throw new RuntimeException("查询用餐人饭卡失败,请稍后重试", exception); + } + if (result == null || !result.isOk() || result.getData() == null) { + throw new RuntimeException(result == null ? "查询用餐人饭卡无响应" + : StrUtil.blankToDefault(result.getMsg(), "查询用餐人饭卡失败")); + } + Map walletMap = new LinkedHashMap<>(); + for (WalletManageEntity wallet : result.getData()) { + if (wallet == null || wallet.getId() == null || wallet.getUserId() == null + || !dinerUserMap.containsKey(wallet.getUserId())) { + throw new RuntimeException("用餐人饭卡数据异常,请联系管理员"); + } + if (walletMap.putIfAbsent(wallet.getUserId(), wallet) != null) { + throw new RuntimeException(reserveDinerDisplayName(dinerUserMap.get(wallet.getUserId())) + "存在重复饭卡数据,请联系管理员"); + } + } + for (Long dinerUserId : dinerUserIds) { + SysUser dinerUser = dinerUserMap.get(dinerUserId); + WalletManageEntity wallet = walletMap.get(dinerUserId); + if (wallet == null) { + throw new RuntimeException(reserveDinerDisplayName(dinerUser) + "未开通饭卡"); + } + if (!Objects.equals(wallet.getUserId(), dinerUserId)) { + throw new RuntimeException(reserveDinerDisplayName(dinerUser) + "的饭卡归属异常,请联系管理员"); + } + if (wallet.getBalance() == null || wallet.getBalance() < requiredPricePerDiner) { + throw new RuntimeException(reserveDinerDisplayName(dinerUser) + "饭卡余额不足"); + } + } + return walletMap; + } + + /** + * 按姓名、昵称、用户名顺序生成人员提示,确保多人预检失败时用户能定位需要处理的饭卡。 + * + * @param dinerUser 实际用餐人资料 + * @return 可用于异常提示的稳定名称 + */ + private String reserveDinerDisplayName(SysUser dinerUser) { + if (dinerUser == null) { + return "用餐人"; + } + return StrUtil.blankToDefault(dinerUser.getName(), + StrUtil.blankToDefault(dinerUser.getNickname(), StrUtil.blankToDefault(dinerUser.getUsername(), "用餐人"))); } /** @@ -1962,21 +2111,40 @@ public class HardOrderServiceImpl extends ServiceImpl result = walletTransactionService.reduceWalletBalanceIdempotent(reduceDto); - if (result == null || !result.isOk() || result.getData() == null) { - throw new RuntimeException(result == null ? "钱包扣款无响应" : result.getMsg()); + private void reduceReserveOrderBalances(List orders) { + List paymentList = buildReserveWalletTransactions(orders); + R> result = walletTransactionService.reduceWalletBalanceBatchIdempotent(paymentList); + if (result == null || !result.isOk() || result.getData() == null || result.getData().size() != paymentList.size()) { + throw new RuntimeException(result == null ? "钱包批量扣款无响应" + : StrUtil.blankToDefault(result.getMsg(), "钱包批量扣款结果异常")); } } + /** + * 将预约订单转换为钱包支付或补偿使用的稳定明细,同一订单 ID 在扣款与补偿阶段始终使用同一业务号。 + * + * @param orders 已创建的预约订单 + * @return 与订单顺序一致的钱包支付明细 + */ + private List buildReserveWalletTransactions(List orders) { + return orders.stream().map(order -> { + if (order.getId() == null || order.getWalletId() == null || order.getPayPrice() == null || order.getPayPrice() <= 0) { + throw new RuntimeException("订餐钱包交易数据异常"); + } + WalletBalanceTransactionDto transaction = new WalletBalanceTransactionDto(); + transaction.setWalletId(order.getWalletId()); + transaction.setPrice(order.getPayPrice()); + transaction.setBizId(String.valueOf(order.getId())); + transaction.setBizType(WalletBizEnum.PAYMENT); + transaction.setForce(false); + return transaction; + }).toList(); + } + /** * 扣库存前按整批人数汇总每个商品的需求,先拒绝商品缺失、跨商户和库存不足。 */ @@ -2036,7 +2204,23 @@ public class HardOrderServiceImpl extends ServiceImpl sourceCarList, int dinerCount, boolean reduce, Map inventorySnapshot) { + private void updateReserveBatchInventory(List sourceCarList, int dinerCount, boolean reduce, + Map inventorySnapshot, String inventoryBizId) { // DTO 始终携带原始扣减量;食堂服务根据 operation 决定应用扣减还是反向恢复。 int factor = -dinerCount; CanteenSpuUpdateStockReqDTO stockDto = new CanteenSpuUpdateStockReqDTO(); - stockDto.setBizId(buildReserveInventoryBizId(sourceCarList)); + stockDto.setBizId(inventoryBizId); stockDto.setOperation(reduce ? "DEDUCT" : "ROLLBACK"); Map stockChanges = sourceCarList.stream().collect(Collectors.toMap( HardCarEntity::getProductId, @@ -2112,22 +2319,38 @@ public class HardOrderServiceImpl extends ServiceImpl sourceCarList) { - Long sourceParentCarId = sourceCarList.stream() - .map(HardCarEntity::getParentCarId) + private String buildReserveInventoryBizId(List createdOrders) { + Long firstOrderId = createdOrders.stream() + .map(HardOrderEntity::getId) .filter(Objects::nonNull) .findFirst() - .orElseThrow(() -> new RuntimeException("购物车数据异常")); - return "HARD_RESERVE_CART:" + sourceParentCarId; + .orElseThrow(() -> new RuntimeException("订餐订单批次数据异常")); + return "HARD_RESERVE_ORDER_BATCH:" + firstOrderId; } - private void compensateReserveOrderBatch(List createdOrders, List sourceCarList, int dinerCount, Map inventorySnapshot) { - try { - updateReserveBatchInventory(sourceCarList, dinerCount, false, inventorySnapshot); - } catch (Exception compensationException) { - log.error("|{}|多人订餐库存补偿失败", TAG, compensationException); + /** + * 补偿旧线上订餐已经尝试的跨服务库存与钱包操作;尚未发起库存请求时不写入远程回滚状态。 + * + * @param createdOrders 已分配 ID 的本次订单 + * @param sourceCarList 原始购物车商品 + * @param dinerCount 实际用餐人数 + * @param inventorySnapshot 扣减前库存快照 + * @param inventoryBizId 已尝试扣库存时的批次号;未尝试时为空 + */ + private void compensateReserveOrderBatch(List createdOrders, List sourceCarList, + int dinerCount, Map inventorySnapshot, String inventoryBizId) { + if (StrUtil.isNotBlank(inventoryBizId)) { + try { + updateReserveBatchInventory(sourceCarList, dinerCount, false, inventorySnapshot, inventoryBizId); + } catch (Exception compensationException) { + log.error("|{}|多人订餐库存补偿失败,inventoryBizId:|{}|", TAG, inventoryBizId, compensationException); + } } compensateReserveOrderWallets(createdOrders); // 克隆购物车与订单处于同一本地事务中,会随订单一并回滚;原购物车刻意保留,便于整批失败后重试。 @@ -2139,24 +2362,18 @@ public class HardOrderServiceImpl extends ServiceImpl createdOrders) { - // 对所有已创建订单发起幂等补偿;钱包服务只会退回实际存在的 PAYMENT 流水。 - ListIterator walletIterator = createdOrders.listIterator(createdOrders.size()); - while (walletIterator.hasPrevious()) { - HardOrderEntity order = walletIterator.previous(); - try { - WalletBalanceTransactionDto refundDto = new WalletBalanceTransactionDto(); - refundDto.setWalletId(order.getWalletId()); - refundDto.setPrice(order.getPayPrice()); - refundDto.setBizId(String.valueOf(order.getId())); - refundDto.setBizType(WalletBizEnum.PAYMENT); - refundDto.setForce(false); - R result = walletTransactionService.compensatePayment(refundDto); - if (result == null || !result.isOk() || !Boolean.TRUE.equals(result.getData())) { - throw new RuntimeException(result == null ? "钱包补偿无响应" : result.getMsg()); - } - } catch (Exception compensationException) { - log.error("|{}|多人订餐钱包补偿失败,orderId:|{}|", TAG, order.getId(), compensationException); + if (createdOrders.isEmpty()) { + return; + } + try { + R result = walletTransactionService.compensatePaymentBatch(buildReserveWalletTransactions(createdOrders)); + if (result == null || !result.isOk() || !Boolean.TRUE.equals(result.getData())) { + throw new RuntimeException(result == null ? "钱包批量补偿无响应" + : StrUtil.blankToDefault(result.getMsg(), "钱包批量补偿失败")); } + } catch (Exception compensationException) { + log.error("|{}|多人订餐钱包批量补偿失败,orderIds:|{}|", TAG, + createdOrders.stream().map(HardOrderEntity::getId).toList(), compensationException); } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml index 240fa0b8c..7d389be70 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml @@ -33,6 +33,9 @@ a.appt_id, a.appt_rule_id, a.appt_use_date, + a.auto_refund_config_id, + a.auto_refund_rule_type, + a.auto_refund_deadline, a.sys_type, a.total_price, a.extra_price, diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJobTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJobTest.java new file mode 100644 index 000000000..3c743815c --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJobTest.java @@ -0,0 +1,62 @@ +package com.bsd.pjcloud.hard.job; + +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum; +import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity; +import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 自动退款补偿调度器的批量上限和租户上下文恢复测试。 + */ +@ExtendWith(MockitoExtension.class) +class HardAutoRefundTaskJobTest { + + /** + * 自动退款任务服务 Mock。 + */ + @Mock + private HardAutoRefundTaskService taskService; + + /** + * 被测自动退款调度器。 + */ + @InjectMocks + private HardAutoRefundTaskJob job; + + /** + * 清理测试线程租户上下文,避免影响同进程其他用例。 + */ + @AfterEach + void tearDown() { + TenantContextHolder.setTenantId(null); + } + + /** + * 单轮应最多请求一百条任务,并在任务租户上下文中触发定时重试。 + */ + @Test + void scheduledScanShouldRestoreTaskTenantBeforeExecution() { + HardAutoRefundTaskEntity task = new HardAutoRefundTaskEntity(); + task.setId(10L); + task.setTenantId(66L); + when(taskService.listScanCandidates(100)).thenReturn(List.of(task)); + + job.retryDueTasks(); + + verify(taskService).listScanCandidates(100); + verify(taskService).execute(10L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null); + assertNull(TenantContextHolder.getTenantId()); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java index 5386cf610..cfa57c56a 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java @@ -299,8 +299,10 @@ class HardApptReservationServiceImplTest { HardApptReservationServiceImpl service = reservationService(weekly, 0, List.of(rule)); LocalDateTime now = LocalDateTime.of(2026, 7, 24, 18, 0); - assertEquals(100L, service.validateReservation(10L, null, 100L, "1", - LocalDate.of(2026, 7, 28), null, now).apptRuleId()); + var validation = service.validateReservation(10L, null, 100L, "1", + LocalDate.of(2026, 7, 28), null, now); + assertEquals(100L, validation.apptRuleId()); + assertEquals(LocalDateTime.of(2026, 7, 26, 22, 0), validation.eligibilityDeadline()); assertThrows(RuntimeException.class, () -> service.validateReservation(10L, null, 999L, "1", LocalDate.of(2026, 7, 28), null, now)); assertThrows(RuntimeException.class, () -> service.validateReservation(10L, null, 100L, "1", diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java new file mode 100644 index 000000000..5a9aa20cd --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java @@ -0,0 +1,180 @@ +package com.bsd.pjcloud.hard.service.impl.order; + +import com.baomidou.lock.LockTemplate; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum; +import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum; +import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity; +import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleMapper; +import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundAttemptLogMapper; +import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundTaskMapper; +import com.bsd.pjcloud.hard.service.HardAfterSaleService; +import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchAutoRefundConfigService; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.feign.RemoteWalletRechargeService; +import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Hardware 自动退款失败计数、固定重试间隔和耗尽状态测试。 + */ +@ExtendWith(MockitoExtension.class) +class HardAutoRefundTaskServiceImplTest { + + /** 自动退款尝试日志 Mapper Mock。 */ + @Mock + private HardAutoRefundAttemptLogMapper attemptLogMapper; + /** 售后 Mapper Mock。 */ + @Mock + private HardAfterSaleMapper afterSaleMapper; + /** 售后服务延迟提供器 Mock。 */ + @Mock + private ObjectProvider afterSaleServiceProvider; + /** Wallet 自动退款配置远程服务 Mock。 */ + @Mock + private RemoteMchAutoRefundConfigService configService; + /** Wallet 退款记录远程服务 Mock。 */ + @Mock + private RemoteWalletRechargeService walletRechargeService; + /** 商户远程服务 Mock。 */ + @Mock + private RemoteMchService mchService; + /** 用户远程服务 Mock。 */ + @Mock + private RemoteUserService userService; + /** 订单分布式锁模板 Mock。 */ + @Mock + private LockTemplate lockTemplate; + /** 自动退款任务 Mapper Mock。 */ + @Mock + private HardAutoRefundTaskMapper taskMapper; + + /** 被测自动退款任务服务。 */ + private HardAutoRefundTaskServiceImpl service; + + /** + * 初始化服务并注入 ServiceImpl 的任务 Mapper。 + */ + @BeforeEach + void setUp() { + service = new HardAutoRefundTaskServiceImpl(attemptLogMapper, afterSaleMapper, afterSaleServiceProvider, + configService, walletRechargeService, mchService, userService, lockTemplate); + ReflectionTestUtils.setField(service, "baseMapper", taskMapper); + when(attemptLogMapper.insert(any(HardAutoRefundAttemptLogEntity.class))).thenReturn(1); + when(taskMapper.updateById(any(HardAutoRefundTaskEntity.class))).thenReturn(1); + } + + /** + * 首次同步执行失败应计为第一次自动尝试并在一分钟后进入等待重试。 + */ + @Test + void firstFailureShouldScheduleOneMinuteRetry() { + HardAutoRefundTaskEntity task = task(0, 0); + when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task); + LocalDateTime before = LocalDateTime.now(); + + service.recordFailureInTransaction(1L, HardAutoRefundTriggerTypeEnum.INITIAL, null, "wallet timeout"); + + LocalDateTime after = LocalDateTime.now(); + assertEquals(1, task.getAutoAttemptCount()); + assertEquals(1, task.getTotalAttemptCount()); + assertEquals(HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus(), task.getStatus()); + assertTrue(!task.getNextRetryTime().isBefore(before.plusMinutes(1)) + && !task.getNextRetryTime().isAfter(after.plusMinutes(1))); + } + + /** + * 第八次自动失败应进入重试耗尽且不再保留下次执行时间。 + */ + @Test + void eighthAutomaticFailureShouldBecomeExhausted() { + HardAutoRefundTaskEntity task = task(7, 7); + when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task); + + service.recordFailureInTransaction(1L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null, "wallet timeout"); + + assertEquals(8, task.getAutoAttemptCount()); + assertEquals(8, task.getTotalAttemptCount()); + assertEquals(HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus(), task.getStatus()); + assertNull(task.getNextRetryTime()); + } + + /** + * 人工技术重试失败只增加总尝试次数,不增加八次自动额度并保持耗尽告警。 + */ + @Test + void manualFailureShouldRemainExhaustedWithoutIncreasingAutoCount() { + HardAutoRefundTaskEntity task = task(8, 8); + when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task); + + service.recordFailureInTransaction(1L, HardAutoRefundTriggerTypeEnum.MANUAL, 99L, "wallet timeout"); + + assertEquals(8, task.getAutoAttemptCount()); + assertEquals(9, task.getTotalAttemptCount()); + assertEquals(HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus(), task.getStatus()); + ArgumentCaptor captor = ArgumentCaptor.forClass(HardAutoRefundAttemptLogEntity.class); + verify(attemptLogMapper).insert(captor.capture()); + assertEquals(99L, captor.getValue().getOperatorUserId()); + } + + /** + * 开关关闭后若 Wallet 已存在退款记录,Hardware 应通过幂等调用恢复售后和任务成功状态且不消耗自动额度。 + */ + @Test + void disabledConfigWithPersistedWalletRefundShouldRecoverHardwareState() { + HardAutoRefundTaskEntity task = task(1, 1); + MchAutoRefundConfigVo config = new MchAutoRefundConfigVo(); + config.setId(5L); + config.setOrderType("2"); + config.setRuleType("PAY_TIME_LIMIT"); + config.setEnabled(YesNoEnum.NO.getCode()); + WalletRechargeEntity persistedRefund = new WalletRechargeEntity(); + persistedRefund.setPayRefundOrderId(2L); + HardAfterSaleService afterSaleService = org.mockito.Mockito.mock(HardAfterSaleService.class); + when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task); + when(configService.getExact(4L, "2")).thenReturn(R.ok(config)); + when(walletRechargeService.getRefundByAfterSaleId(2L)).thenReturn(R.ok(persistedRefund)); + when(afterSaleServiceProvider.getObject()).thenReturn(afterSaleService); + when(attemptLogMapper.updateById(any(HardAutoRefundAttemptLogEntity.class))).thenReturn(1); + + service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null); + + verify(afterSaleService).completeAutoRefund(2L, 5L); + assertEquals(1, task.getAutoAttemptCount()); + assertEquals(2, task.getTotalAttemptCount()); + assertEquals(HardAutoRefundTaskStatusEnum.SUCCESS.getStatus(), task.getStatus()); + } + + /** + * 构造固定订单和售后单的自动退款任务。 + * + * @param autoAttempts 已消耗自动尝试次数 + * @param totalAttempts 全部尝试序号计数 + * @return 待记录失败的任务 + */ + private HardAutoRefundTaskEntity task(int autoAttempts, int totalAttempts) { + return HardAutoRefundTaskEntity.builder().id(1L).afterSaleId(2L).orderId(3L).mchId(4L) + .configId(5L).ruleType("PAY_TIME_LIMIT").status(HardAutoRefundTaskStatusEnum.PENDING.getStatus()) + .autoAttemptCount(autoAttempts).totalAttemptCount(totalAttempts).build(); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/logs/pjcloud-thirdparty-biz/2026-06/debug.2026-06-01.0.log.gz b/pjcloud-thirdparty/pjcloud-thirdparty-biz/logs/pjcloud-thirdparty-biz/2026-06/debug.2026-06-01.0.log.gz new file mode 100644 index 000000000..5e283a25c Binary files /dev/null and b/pjcloud-thirdparty/pjcloud-thirdparty-biz/logs/pjcloud-thirdparty-biz/2026-06/debug.2026-06-01.0.log.gz differ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/pom.xml b/pjcloud-thirdparty/pjcloud-thirdparty-biz/pom.xml index 8778e5079..11ab46402 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/pom.xml +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/pom.xml @@ -146,6 +146,13 @@ spring-boot-starter-test test + + + org.mockito + mockito-core + 5.11.0 + test + com.bsd diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java index 4692cfbcb..a436cf113 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java @@ -19,7 +19,7 @@ import com.wechat.pay.java.core.notification.NotificationParser; import com.wechat.pay.java.core.notification.RequestParam; import com.wechat.pay.java.service.partnerpayments.app.AppServiceExtension; import com.wechat.pay.java.service.partnerpayments.app.model.Transaction; -import com.wechat.pay.java.service.partnerpayments.app.model.TransactionAmount; +import com.wechat.pay.java.service.partnerpayments.model.TransactionAmount; import com.wechat.pay.java.service.refund.RefundService; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/StudentUserExcelVO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/StudentUserExcelVO.java index f9c428ae9..3fcd3fa38 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/StudentUserExcelVO.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/StudentUserExcelVO.java @@ -43,11 +43,6 @@ public class StudentUserExcelVO { */ private Long guardianUserId; - /** - * 监护人部门id - */ - private Long guardianDeptId; - /** * 学号 */ @@ -55,6 +50,19 @@ public class StudentUserExcelVO { @ExcelProperty("学号") private String studentNo; + /** + * 学生所属部门名称或路径,必须匹配当前租户已有部门。 + */ + @NotBlank(message = "学生部门") + @ExcelProperty("学生部门") + private String studentDeptName; + + /** + * 同步校验阶段解析出的学生部门 ID,仅供异步写入使用,不直接读取 Excel。 + */ + @ExcelIgnore + private Long studentDeptId; + /** * 学校名称 */ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysFileController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysFileController.java index 8279a2159..79075beec 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysFileController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysFileController.java @@ -31,6 +31,7 @@ import com.bsd.pjcloud.common.excel.annotation.ExcelTemplate; import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.common.security.annotation.HasPermission; import com.bsd.pjcloud.common.security.annotation.Inner; +import com.bsd.pjcloud.wallet.api.dto.recharge.excel.WalletRechargeExcelDto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; @@ -152,7 +153,7 @@ public class SysFileController { * @param response 本地文件 */ @SneakyThrows - @ExcelTemplate + @ExcelTemplate(WalletRechargeExcelDto.class) @GetMapping("/local/file/{fileName}") public void localFile(@PathVariable String fileName, HttpServletResponse response) { ClassPathResource resource = new ClassPathResource("file/" + fileName); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java index fd1055989..68070e18e 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java @@ -421,7 +421,7 @@ public class SysUserController { * @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度 */ @PostMapping("/import") - @HasPermission("sys_user_export") + @HasPermission("sys_user_import") public R importUser(@RequestExcel List excelVOList, BindingResult bindingResult) { return userImportExportService.importUser(excelVOList, bindingResult); } @@ -479,7 +479,7 @@ public class SysUserController { * @return 校验失败时包含校验或图片处理错误;校验通过时包含异步任务 ID 和初始进度 */ @PostMapping("/import-face") - @HasPermission("sys_user_export") + @HasPermission("sys_user_import") @Idempotent(expireTime = 5, key = "'/user/import-face:' + #file.getSize() + ':' + #excelList.hashCode() + ':' + T(com.bsd.pjcloud.common.security.util.SecurityUtils).getUser().getId()", info = "正在导入中,请勿重复操作") public R importUserFace(@RequestParam("file") MultipartFile file, @RequestExcel List excelList, BindingResult bindingResult) { return userImportExportService.importUserFace(file, excelList, bindingResult); 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/SysUserImportExportServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserImportExportServiceImpl.java index e2e6b12bc..a5b8b1a47 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserImportExportServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserImportExportServiceImpl.java @@ -261,10 +261,13 @@ public class SysUserImportExportServiceImpl implements SysUserImportExportServic excel.setUsername(StrUtil.trim(excel.getUsername())); excel.setGuardianPhone(StrUtil.trim(excel.getGuardianPhone())); excel.setStudentNo(StrUtil.trim(excel.getStudentNo())); + excel.setStudentDeptName(StrUtil.trim(excel.getStudentDeptName())); excel.setSchoolName(StrUtil.trim(excel.getSchoolName())); excel.setClassName(StrUtil.trim(excel.getClassName())); }); + DepartmentIndex departmentIndex = new DepartmentIndex(sysDeptService.list()); + List guardianPhones = studentUserExcelList.stream().map(StudentUserExcelVO::getGuardianPhone) .filter(StrUtil::isNotBlank).distinct().toList(); Map guardianUserMap = guardianPhones.isEmpty() ? Map.of() : sysUserService.list( @@ -331,9 +334,12 @@ public class SysUserImportExportServiceImpl implements SysUserImportExportServic errorMessages.add("监护人手机号:" + excel.getGuardianPhone() + ",对应的用户未添加或未审批通过"); } else { excel.setGuardianUserId(guardian.getUserId()); - excel.setGuardianDeptId(guardian.getDeptId()); } } + SysDept studentDepartment = resolveDepartment(excel.getStudentDeptName(), departmentIndex, errorMessages); + if (studentDepartment != null) { + excel.setStudentDeptId(studentDepartment.getDeptId()); + } if (CollUtil.isNotEmpty(errorMessages)) { errorMessageList.add(new ErrorMessage(excel.getLineNum(), errorMessages)); } @@ -762,7 +768,7 @@ public class SysUserImportExportServiceImpl implements SysUserImportExportServic } /** - * 创建学生用户及监护关系,学生扩展信息只属于 STUDENT 身份。 + * 创建学生用户及监护关系,学生扩展信息只属于 STUDENT 身份,用户部门使用模板中已校验的学生部门。 * * @param excel 当前校验通过的学生行 * @param guardianWardRelationTypeId 监护关系类型 ID @@ -779,7 +785,7 @@ public class SysUserImportExportServiceImpl implements SysUserImportExportServic relationUser.setStaffingType(UserStaffingTypeEnum.NONE_STAFFING.getType()); relationUser.setIdentityTypes(List.of(UserIdentityTypeEnum.STUDENT.getType())); relationUser.setRole(studentRoleIds); - relationUser.setDeptId(excel.getGuardianDeptId()); + relationUser.setDeptId(excel.getStudentDeptId()); SysUserStudentDto studentInfo = new SysUserStudentDto(); studentInfo.setStudentNo(excel.getStudentNo()); studentInfo.setSchoolName(excel.getSchoolName()); 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 69e422f41..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 @@ -192,6 +192,12 @@ public class SysUserServiceImpl extends ServiceImpl impl /** * 保存用户主表、身份、岗位和角色关系,并按审核结果安排后续钱包创建。 + * 只有当前上下文明确定义了 {@code ROLE_ADMIN} 时才保留请求角色;其他操作人以及无认证上下文的调用, + * 请求角色都会被固定替换为普通用户和自动创建钱包角色。免审参数只控制审批流程,不放宽角色权限。 + * + * @param userDto 用户创建参数;普通后台创建场景中的角色可能被安全规则覆盖 + * @param skipAudit 是否跳过新增用户审批,不影响角色选择权限 + * @return 新建用户ID */ private Long saveUser(UserDTO userDto, boolean skipAudit) { sysUserIdentityService.validateDto(userDto); @@ -199,21 +205,21 @@ public class SysUserServiceImpl extends ServiceImpl impl BeanUtils.copyProperties(userDto, sysUser); // 是否进入审批由当前操作人的专用角色控制,不能再通过部门管理员身份推断。 PjcloudUser applyUser = SecurityUtils.getUser(); + List applyUserRoles = applyUser == null ? Collections.emptyList() : sysRoleService.findRolesByUserId(applyUser.getId()); + boolean isRoleAdmin = CollUtil.isNotEmpty(applyUserRoles) && applyUserRoles.stream() + .anyMatch(role -> ObjectUtil.equals(role.getRoleCode(), CommonRoleConstant.ROLE_ADMIN)); + if (!isRoleAdmin) { + // 请求中的角色ID不可信,只有明确具有管理员角色的操作人才能自选新增用户角色。 + userDto.setRole(getNonAdminNewUserRoleIds()); + } boolean needApproval = false; if (skipAudit) { sysUser.setProcessStatus(TaskCheckStatusEnum.RESULT.getCode()); sysUser.setProcessTime(LocalDateTime.now()); } else if (null != applyUser) { - List applyUserRoles = sysRoleService.findRolesByUserId(applyUser.getId()); needApproval = CollUtil.isNotEmpty(applyUserRoles) && applyUserRoles.stream() .anyMatch(role -> ObjectUtil.equals(role.getRoleCode(), CommonRoleConstant.ADD_USER_NEED_CEHCK)); if (needApproval) { - List defaultRoles = sysRoleService.findRolesByCodes(List.of(CommonRoleConstant.DEFAULT_USER, CommonRoleConstant.AUTO_CREATE_WALLET)); - if (ObjectUtil.isEmpty(defaultRoles)) { - userDto.setRole(null); - } else { - userDto.setRole(defaultRoles.stream().map(SysRole::getRoleId).toList()); - } userDto.setDeptIds(null); sysUser.setProcessStatus(TaskCheckStatusEnum.PADDING.getCode()); } else { @@ -267,6 +273,22 @@ public class SysUserServiceImpl extends ServiceImpl impl return sysUser.getUserId(); } + /** + * 查询非管理员新增用户时必须赋予的固定角色ID。 + * + * @return 按普通用户、自动创建钱包顺序排列的角色ID + * @throws IllegalStateException 任一固定角色未在当前租户中配置时抛出 + */ + private List getNonAdminNewUserRoleIds() { + List roleCodes = List.of(CommonRoleConstant.DEFAULT_USER, CommonRoleConstant.AUTO_CREATE_WALLET); + List defaultRoles = Optional.ofNullable(sysRoleService.findRolesByCodes(roleCodes)).orElseGet(Collections::emptyList); + Map roleIdByCode = defaultRoles.stream().collect(Collectors.toMap(SysRole::getRoleCode, SysRole::getRoleId, (first, ignored) -> first)); + if (!roleIdByCode.keySet().containsAll(roleCodes)) { + throw new IllegalStateException("默认用户角色配置不完整,请联系管理员"); + } + return roleCodes.stream().map(roleIdByCode::get).toList(); + } + /** * 通过查用户的全部信息 * @@ -299,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) { @@ -309,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); @@ -477,6 +506,14 @@ public class SysUserServiceImpl extends ServiceImpl impl return R.ok(this.updateById(sysUser)); } + /** + * 更新指定用户的基础资料、身份、岗位和角色关系。 + * 当前操作人只有具备 {@code ROLE_ADMIN} 角色时才能改变目标用户的角色集合;非管理员提交的角色与库中不一致时直接拒绝, + * 一致时忽略该字段并保留原角色关系。 + * + * @param userDto 用户更新参数,必须包含可解析且已存在的用户ID + * @return 用户资料及允许变更的关联信息处理完成后返回 {@code true} + */ @Override @Transactional(rollbackFor = Exception.class) @Audit(name = "用户更新", spel = "@sysUserMapper.selectById(#userDto.userId)") @@ -493,6 +530,7 @@ public class SysUserServiceImpl extends ServiceImpl impl // 以库中主键为准,避免前端 Long 精度丢失后 ne 条件排除失败 userId = dbUser.getUserId(); userDto.setUserId(userId); + protectUserRolesFromUnauthorizedUpdate(userDto, userId); sysUserIdentityService.validateDto(userDto); // 更新用户表信息 SysUser sysUser = new SysUser(); @@ -714,6 +752,30 @@ public class SysUserServiceImpl extends ServiceImpl impl return true; } + /** + * 校验并保护已有用户的角色关系,避免非管理员绕过前端直接篡改角色。 + * + * @param userDto 本次用户更新参数;角色为空表示调用方不更新角色 + * @param userId 库中确认存在的目标用户ID + */ + private void protectUserRolesFromUnauthorizedUpdate(UserDTO userDto, Long userId) { + if (userDto.getRole() == null) { + return; + } + PjcloudUser operator = SecurityUtils.getUser(); + boolean isRoleAdmin = operator != null && sysRoleService.findRolesByUserId(operator.getId()).stream().anyMatch(role -> ObjectUtil.equals(role.getRoleCode(), CommonRoleConstant.ROLE_ADMIN)); + if (isRoleAdmin) { + return; + } + Set currentRoleIds = sysUserRoleMapper.selectList(Wrappers.lambdaQuery().eq(SysUserRole::getUserId, userId)).stream().map(SysUserRole::getRoleId).collect(Collectors.toSet()); + Set submittedRoleIds = new HashSet<>(userDto.getRole()); + if (!currentRoleIds.equals(submittedRoleIds)) { + throw new RuntimeException("仅管理员可以修改用户角色"); + } + // 非管理员提交的角色仅用于一致性校验,校验通过后也不得重写角色关系。 + userDto.setRole(null); + } + /** * 锁定用户 * 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(List userVos) { + 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-upms/pjcloud-upms-biz/src/main/resources/file/student_user_import.xlsx b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/student_user_import.xlsx index 284f54b74..320b93893 100644 Binary files a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/student_user_import.xlsx and b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/student_user_import.xlsx differ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_recharge.xlsx b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_recharge.xlsx index 3d4d1cd2e..d9a724559 100644 Binary files a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_recharge.xlsx and b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_recharge.xlsx differ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml index 2e7c36245..7f61cd4e0 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml @@ -219,6 +219,18 @@ AND u.staffing_type = #{query.staffingType} + + + AND EXISTS ( + SELECT 1 + FROM sys_user_identity user_identity + WHERE user_identity.user_id = u.user_id + AND user_identity.identity_type IN + + #{identityType} + + ) + AND u.lock_flag = #{query.lockFlag} @@ -320,6 +332,18 @@ AND u.dept_id = #{query.deptId} + + + AND EXISTS ( + SELECT 1 + FROM sys_user_identity user_identity + WHERE user_identity.user_id = u.user_id + AND user_identity.identity_type IN + + #{identityType} + + ) + AND u.staffing_type = #{query.staffingType} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTransactionTag.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTransactionTag.java new file mode 100644 index 000000000..0eb4aeefd --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTransactionTag.java @@ -0,0 +1,34 @@ +package com.bsd.pjcloud.wallet.api.constant; + +import cn.hutool.core.util.ObjectUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 充值流水tag + */ +@Getter +@AllArgsConstructor +public enum WalletTransactionTag { + + BJZ_CZ("1", "不记账充值"), + ; + + private final String code; + + private final String desc; + + public static WalletTransactionTag valueOfByCode(String code) { + if (ObjectUtil.isEmpty(code)) return null; + return Arrays.stream(values()).filter(v -> ObjectUtil.equals(code, v.getCode())).findFirst().orElse(null); + } + + public static WalletTransactionTag descOf(String description) { + return Arrays.stream(WalletTransactionTag.values()) + .filter(v -> ObjectUtil.equal(v.getDesc(), description)) + .findFirst().orElse(null); + } + +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTypeEnum.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTypeEnum.java index e71c525bb..7630e505e 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTypeEnum.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTypeEnum.java @@ -23,12 +23,12 @@ public enum WalletTypeEnum { public static WalletTypeEnum valueOfByCode(String code) { if (ObjectUtil.isEmpty(code)) return null; - return Arrays.stream(values()).filter(v -> ObjectUtil.equals(code, v.getCode())).findFirst().get(); + return Arrays.stream(values()).filter(v -> ObjectUtil.equals(code, v.getCode())).findFirst().orElse(null); } public static WalletTypeEnum descOf(String description) { return Arrays.stream(WalletTypeEnum.values()) .filter(v -> ObjectUtil.equal(v.getDesc(), description)) - .findFirst().get(); + .findFirst().orElse(null); } } 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/WalletBalanceTransactionDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletBalanceTransactionDto.java index 2e7d0e0f8..5faf255e1 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletBalanceTransactionDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletBalanceTransactionDto.java @@ -32,4 +32,9 @@ public class WalletBalanceTransactionDto { @NotNull(message = "是否暴力消费") private Boolean force = false; + /** + * 财务统计标签字典值,多个值使用英文逗号分隔;未传时不为流水设置标签。 + */ + private String tags; + } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionDto.java index 3fdf245f4..4a98183d6 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionDto.java @@ -39,6 +39,11 @@ public class WalletTransactionDto extends WalletTransactionEntity implements Bas private List bizTypes; + /** + * 财务统计标签筛选值;传入多个字典值时匹配包含任一标签的流水。 + */ + private List tagList; + private LocalDate[] createTimeList; /** 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/WalletRechargeImportDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeImportDto.java index 0220b4202..32b451d62 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeImportDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeImportDto.java @@ -18,6 +18,11 @@ public class WalletRechargeImportDto extends WalletRechargeImportEntity implemen private List userUniqueIds; + /** + * 财务统计标签筛选值;传入多个字典值时匹配包含任一标签的导入记录。 + */ + private List tagList; + private Long userDeptId; 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/dto/recharge/excel/WalletRechargeExcelDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletRechargeExcelDto.java index 6697c9dfa..577256eb1 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletRechargeExcelDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletRechargeExcelDto.java @@ -6,6 +6,7 @@ import com.alibaba.excel.annotation.ExcelProperty; import com.alibaba.excel.annotation.write.style.ColumnWidth; import com.alibaba.excel.annotation.write.style.ContentRowHeight; import com.alibaba.excel.annotation.write.style.HeadRowHeight; +import com.bsd.pjcloud.common.excel.annotation.ExcelDict; import com.bsd.pjcloud.common.excel.annotation.ExcelLine; import lombok.AllArgsConstructor; import lombok.Builder; @@ -42,6 +43,20 @@ public class WalletRechargeExcelDto { @ExcelProperty("操作金额") private String price; + /** + * Excel 中展示和填写的财务标签名称;支持使用英文逗号分隔多个 {@code wallet_transaction_tag} 字典标签。 + */ + @ColumnWidth(100) + @ExcelProperty("操作标签") + @ExcelDict(value = "wallet_transaction_tag", multiple = true) + private String tag; + + /** + * 导入校验后解析出的财务标签字典值,多个值使用英文逗号分隔,不直接读取 Excel。 + */ + @ExcelIgnore + private String tags; + // 操作余额 private Integer priceNumber; diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java index 55d771e39..7e0529e89 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java @@ -78,6 +78,12 @@ public class WalletTransactionEntity extends Model { @Schema(description = "余额, 单位分") private Integer balance; + /** + * 财务统计标签,保存 {@code wallet_transaction_tag} 字典值;多个值使用英文逗号分隔,历史流水可为空。 + */ + @Schema(description = "财务统计标签,多个字典值使用英文逗号分隔") + private String tags; + /** * 创建人 */ @@ -119,4 +125,4 @@ public class WalletTransactionEntity extends Model { */ @Schema(description = "租户ID") private Long tenantId; -} \ No newline at end of file +} 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/entity/recharge/WalletRechargeImportEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeImportEntity.java index fc35e52fc..d006d8dd1 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeImportEntity.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeImportEntity.java @@ -67,6 +67,12 @@ public class WalletRechargeImportEntity extends Model 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/config/ExcelDictConfiguration.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/config/ExcelDictConfiguration.java new file mode 100644 index 000000000..570e2678c --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/config/ExcelDictConfiguration.java @@ -0,0 +1,36 @@ +package com.bsd.pjcloud.wallet.config; + +import com.bsd.pjcloud.admin.api.entity.SysDictItem; +import com.bsd.pjcloud.admin.api.feign.RemoteDictService; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.excel.dict.ExcelDictProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Collections; +import java.util.List; + +/** + * 钱包服务 Excel 字典配置。 + */ +@Configuration +public class ExcelDictConfiguration { + + /** + * 通过 UPMS 远程字典接口提供当前租户的 Excel 下拉标签。 + * + * @param remoteDictService UPMS 字典远程服务 + * @return 钱包服务使用的 Excel 字典数据提供者;远程响应异常或无数据时返回空标签列表 + */ + @Bean + public ExcelDictProvider excelDictProvider(RemoteDictService remoteDictService) { + return dictType -> { + R> result = remoteDictService.getDictByType(dictType); + if (result == null || !result.isOk() || result.getData() == null) { + return Collections.emptyList(); + } + return result.getData().stream().map(SysDictItem::getLabel).toList(); + }; + } + +} 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 d9498856d..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 @@ -56,7 +56,7 @@ public class WalletTransactionController { @Inner @PostMapping("/add-balance") public R addWalletBalance(@Valid @RequestBody WalletBalanceTransactionDto dto) { - WalletTransactionEntity walletTransaction = walletTransactionService.addWalletBalance(dto.getWalletId(), dto.getPrice(), dto.getBizId(), dto.getBizType()); + WalletTransactionEntity walletTransaction = walletTransactionService.addWalletBalance(dto.getWalletId(), dto.getPrice(), dto.getBizId(), dto.getBizType(), dto.getTags()); if (ObjectUtil.isEmpty(walletTransaction)) return R.failed("增加钱包余额错误"); return R.ok(walletTransaction); } @@ -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/WalletTransactionMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletTransactionMapper.java index 24e1d0a85..225e311f0 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletTransactionMapper.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletTransactionMapper.java @@ -61,6 +61,20 @@ public interface WalletTransactionMapper extends PjcloudBaseMapper { + boolean first = true; + for (String tag : dto.getTagList()) { + if (first) { + nested.apply("FIND_IN_SET({0}, tags) > 0", tag); + first = false; + } + else { + nested.or().apply("FIND_IN_SET({0}, tags) > 0", tag); + } + } + }); + } return wrapper; } @@ -98,5 +112,11 @@ public interface WalletTransactionMapper extends PjcloudBaseMapper sumAndCount(@Param("dto") WalletTransactionSumAndCountDto dto); - List exportCzTotalPerson(); -} \ No newline at end of file + /** + * 查询固定金额范围内的个人充值统计明细,并按财务标签筛选。 + * + * @param dto 充值统计筛选条件;标签多选时匹配包含任一标签的流水 + * @return 个人充值统计导出明细 + */ + List exportCzTotalPerson(@Param("dto") WalletTransactionDto dto); +} 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 3f6543476..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); + /** * 增加钱包余额 @@ -69,6 +86,18 @@ public interface WalletTransactionService extends IService list); /** 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) @@ -212,6 +565,22 @@ public class WalletTransactionServiceImpl extends ServiceImpl list = baseMapper.exportCzTotalPerson(); + List list = baseMapper.exportCzTotalPerson(dto); WriteSheet writeSheet = EasyExcel.writerSheet().build(); FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build(); 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/WalletRechargeApplyServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java index e4a2f74ed..a755fa87b 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java @@ -12,8 +12,10 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bsd.pjcloud.admin.api.dto.UserInfo; import com.bsd.pjcloud.admin.api.entity.SysDept; +import com.bsd.pjcloud.admin.api.entity.SysDictItem; import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; +import com.bsd.pjcloud.admin.api.feign.RemoteDictService; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; import com.bsd.pjcloud.admin.api.utils.DataUtil; import com.bsd.pjcloud.admin.api.vo.UserVO; @@ -90,6 +92,11 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl batchSaveList = list.stream().map(v -> WalletRechargeImportEntity.builder().price(v.getPriceNumber()).userId(v.getUserId()).userDeptId(v.getUserDeptId()).build()).toList(); + List batchSaveList = list.stream().map(v -> WalletRechargeImportEntity.builder().price(v.getPriceNumber()).userId(v.getUserId()).userDeptId(v.getUserDeptId()).tags(v.getTags()).build()).toList(); batchSaveList.forEach(v -> v.setApplyId(insertObj.getId())); rechargeImportService.saveBatch(batchSaveList); return R.ok(null, "导入成功"); @@ -349,7 +357,7 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl batchSaveList = list.stream().map(v -> WalletRechargeImportEntity.builder().price(0).userId(v.getUserId()).userDeptId(v.getUserDeptId()).build()).toList(); + List batchSaveList = list.stream().map(v -> WalletRechargeImportEntity.builder().price(0).userId(v.getUserId()).userDeptId(v.getUserDeptId()).tags(v.getTags()).build()).toList(); batchSaveList.forEach(v -> v.setApplyId(insertObj.getId())); rechargeImportService.saveBatch(batchSaveList); return R.ok(null, "导入成功"); @@ -371,7 +379,7 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl batchSaveList = list.stream().map(v -> WalletRechargeImportEntity.builder().price(v.getPriceNumber()).userId(v.getUserId()).userDeptId(v.getUserDeptId()).invoiceId(invoice.getId()).build()).toList(); + List batchSaveList = list.stream().map(v -> WalletRechargeImportEntity.builder().price(v.getPriceNumber()).userId(v.getUserId()).userDeptId(v.getUserDeptId()).invoiceId(invoice.getId()).tags(v.getTags()).build()).toList(); rechargeImportService.saveBatch(batchSaveList); return R.ok(null, "导入成功"); } @@ -388,6 +396,7 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl errorMessageList = (List) bindingResult.getTarget(); + resolveTransactionTagValues(list, errorMessageList); List usernameList = list.stream().map(WalletRechargeExcelDto::getUsername).toList(); List userPoneList = list.stream().map(WalletRechargeExcelDto::getUserMobile).toList(); for (WalletRechargeExcelDto v : list) { @@ -442,6 +451,41 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl list, List errorMessageList) { + List taggedRows = list.stream().filter(item -> ObjectUtil.isNotEmpty(item.getTag())).toList(); + if (taggedRows.isEmpty()) { + return; + } + R> result = remoteDictService.getDictByType(TRANSACTION_TAG_DICT_TYPE); + if (result == null || !result.isOk() || ObjectUtil.isEmpty(result.getData())) { + taggedRows.forEach(item -> errorMessageList.add(new ErrorMessage(item.getLineNum(), Set.of("操作标签字典查询失败")))); + return; + } + Map valueByLabel = result.getData().stream() + .filter(item -> ObjectUtil.isNotEmpty(item.getLabel()) && ObjectUtil.isNotEmpty(item.getItemValue())) + .collect(java.util.stream.Collectors.toMap(SysDictItem::getLabel, SysDictItem::getItemValue, (first, ignored) -> first)); + for (WalletRechargeExcelDto item : taggedRows) { + List tagValues = Arrays.stream(item.getTag().split(",")) + .map(String::trim) + .distinct() + .map(valueByLabel::get) + .filter(Objects::nonNull) + .toList(); + long selectedTagCount = Arrays.stream(item.getTag().split(",")).map(String::trim).distinct().count(); + if (tagValues.size() != selectedTagCount) { + errorMessageList.add(new ErrorMessage(item.getLineNum(), Set.of("操作标签包含无法映射的字典项"))); + continue; + } + item.setTags(String.join(",", tagValues)); + } + } + /* ************************************* end excel end ************************************* */ @Override @@ -497,7 +541,7 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl userIds = importList.stream().map(WalletRechargeImportEntity::getUserId).distinct().toList(); @@ -548,7 +592,7 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl + @@ -22,6 +23,7 @@ a.title, a.price, a.balance, + a.tags, a.create_by, a.create_time, a.update_by, @@ -44,6 +46,13 @@ and a.biz_type = #{dto.bizType} + + and ( + + FIND_IN_SET(#{tag}, a.tags) > 0 + + ) + and a.dept_id = #{dto.deptId} @@ -286,6 +295,13 @@ and t.biz_type in ('1', '2', '3') and t.price in (80000, 160000, 240000) and t.dept_id not in (1875013738914156546, 1910218102049226753, 1910218128624336898, 1942046316044144642) + + and ( + + FIND_IN_SET(#{tag}, t.tags) > 0 + + ) + order by v.mchId, v.deptId @@ -303,4 +319,4 @@ ) - \ No newline at end of file + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeImportMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeImportMapper.xml index 2d7394b36..086f0d75b 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeImportMapper.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeImportMapper.xml @@ -9,6 +9,7 @@ + @@ -24,6 +25,7 @@ a.apply_id, a.before_price, a.price, + a.tags, a.create_by, a.create_time, a.update_by, @@ -71,6 +73,13 @@ and a.create_time between #{dto.createTimeList[0]} and #{dto.createTimeList[1]} + + and ( + + FIND_IN_SET(#{tag}, a.tags) > 0 + + ) + @@ -134,4 +143,4 @@ - \ No newline at end of file + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchAutoRefundConfigServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchAutoRefundConfigServiceImplTest.java new file mode 100644 index 000000000..3a9aa5cd9 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchAutoRefundConfigServiceImplTest.java @@ -0,0 +1,131 @@ +package com.bsd.pjcloud.wallet.service.impl.mch; + +import com.bsd.pjcloud.wallet.api.constant.mch.MchAutoRefundRuleTypeEnum; +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.mapper.mch.MchAutoRefundConfigMapper; +import com.bsd.pjcloud.wallet.service.MchManageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 商户自动退款配置边界和持久化规则测试。 + */ +@ExtendWith(MockitoExtension.class) +class MchAutoRefundConfigServiceImplTest { + + /** + * 商户数据范围服务 Mock,用于放行固定测试商户。 + */ + @Mock + private MchManageService mchManageService; + + /** + * 自动退款配置 Mapper Mock,用于捕获规范化后的配置值。 + */ + @Mock + private MchAutoRefundConfigMapper configMapper; + + /** + * 被测自动退款配置服务。 + */ + private MchAutoRefundConfigServiceImpl service; + + /** + * 初始化服务并把 ServiceImpl 的 Mapper 替换为测试 Mock。 + */ + @BeforeEach + void setUp() { + service = new MchAutoRefundConfigServiceImpl(mchManageService); + ReflectionTestUtils.setField(service, "baseMapper", configMapper); + MchManageVo merchant = new MchManageVo(); + merchant.setId(10L); + when(mchManageService.scopeList(any(), any())).thenReturn(List.of(merchant)); + lenient().when(configMapper.selectExactForUpdate(anyLong(), eq("2"))).thenReturn(null); + lenient().when(configMapper.insert(any(MchAutoRefundConfigEntity.class))).thenReturn(1); + } + + /** + * 支付后限时规则应接受 1 和 168 小时两个闭区间边界。 + */ + @Test + void payTimeLimitShouldAcceptClosedIntervalBoundaries() { + service.saveOrUpdateConfig(config(MchAutoRefundRuleTypeEnum.PAY_TIME_LIMIT.name(), 1)); + service.saveOrUpdateConfig(config(MchAutoRefundRuleTypeEnum.PAY_TIME_LIMIT.name(), 168)); + + ArgumentCaptor 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; + } +}