Merge remote-tracking branch 'origin/master'
# Conflicts: # pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml
This commit is contained in:
commit
40c5b409fd
94 files changed
+5228
-188
No files matched your search
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 '修改人',
|
||||
|
||||
+8
@@ -9,6 +9,7 @@ import java.lang.annotation.Target;
|
||||
/**
|
||||
* Excel 字典字段。
|
||||
* <p>导入时校验单元格内容是否为字典标签,导出模板时将同一字典标签写入下拉选项。</p>
|
||||
* <p>多选字段在普通 Excel 中使用英文逗号分隔多个标签;原生下拉仅负责提供选项,无法自动追加多次选择。</p>
|
||||
*/
|
||||
@Documented
|
||||
@Target(ElementType.FIELD)
|
||||
@@ -20,4 +21,11 @@ public @interface ExcelDict {
|
||||
*/
|
||||
String value();
|
||||
|
||||
/**
|
||||
* 是否允许一个单元格填写多个字典标签。
|
||||
*
|
||||
* @return {@code true} 时按英文逗号拆分并逐项校验,{@code false} 时整个单元格只能是一个字典标签
|
||||
*/
|
||||
boolean multiple() default false;
|
||||
|
||||
}
|
||||
+8
-1
@@ -8,11 +8,18 @@ import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Excel 模板增强。
|
||||
* <p>切面自动收集 {@link RequestExcel} 导入参数中的模型,下载接口无需感知文件名或具体业务模型。</p>
|
||||
* <p>切面默认自动收集当前服务 {@link RequestExcel} 导入参数中的模型;跨服务模型可通过注解参数显式补充。</p>
|
||||
*/
|
||||
@Documented
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface ExcelTemplate {
|
||||
|
||||
/**
|
||||
* 当前下载接口需要额外处理的 Excel 模型,用于补充当前服务无法自动扫描到的跨服务导入模型。
|
||||
*
|
||||
* @return 需要参与模板字典下拉增强的模型类型;默认不额外补充模型
|
||||
*/
|
||||
Class<?>[] value() default {};
|
||||
|
||||
}
|
||||
+15
-3
@@ -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<T>} 是模板模型的唯一来源,新增导入模型无需再修改下载接口或注册表。
|
||||
* 自动收集当前服务导入接口中的 {@code @RequestExcel List<T>} 模型,供未显式注册的同服务模板继续使用。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+27
-6
@@ -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<String> 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);
|
||||
}
|
||||
|
||||
|
||||
+18
-4
@@ -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<Obje
|
||||
}
|
||||
|
||||
/**
|
||||
* 字典字段统一按 Excel 中展示的标签校验,避免业务 Service 为每个字段重复编写字典判断。
|
||||
* 字典字段统一按 Excel 中展示的标签校验;多选字段按英文逗号拆分,避免业务 Service 重复编写字典判断。
|
||||
*
|
||||
* @param data 当前 Excel 行转换出的模型
|
||||
* @return 当前行字典字段的校验错误;没有错误时返回空集合
|
||||
*/
|
||||
private Set<String> validateExcelDict(Object data) {
|
||||
Set<String> messages = new LinkedHashSet<>();
|
||||
@@ -82,14 +86,24 @@ public class DefaultAnalysisEventListener extends ListAnalysisEventListener<Obje
|
||||
if (fieldValue == null || !StringUtils.hasText(fieldValue.toString())) {
|
||||
continue;
|
||||
}
|
||||
String label = fieldValue.toString().trim();
|
||||
String fieldText = fieldValue.toString().trim();
|
||||
List<String> 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<String> 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<String> invalidLabels = selectedLabels.stream().filter(label -> !labels.contains(label)).distinct().toList();
|
||||
if (!invalidLabels.isEmpty()) {
|
||||
messages.add(fieldName + ":" + String.join(",", invalidLabels) + ",不在可选项中");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IllegalAccessException exception) {
|
||||
|
||||
+32
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
+51
@@ -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;
|
||||
}
|
||||
}
|
||||
+73
@@ -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);
|
||||
}
|
||||
}
|
||||
+53
@@ -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;
|
||||
}
|
||||
}
|
||||
+57
@@ -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<Long> mchIds;
|
||||
}
|
||||
+3
-3
@@ -14,7 +14,7 @@ import java.util.List;
|
||||
* App 每日菜谱多日期、多餐段批量订餐请求。
|
||||
*
|
||||
* <p>一个分组对应一个父购物车和一个预约日期餐段,服务端会为每个分组下的每位实际用餐人
|
||||
* 创建独立订单,并统一从当前付款人选择的钱包扣款。</p>
|
||||
* 创建独立订单,并从各实际用餐人本人已经开通的 EAT 饭卡扣款。</p>
|
||||
*/
|
||||
@Data
|
||||
public class HardAppCreateDailyMenuReserveOrdersDto {
|
||||
@@ -34,9 +34,9 @@ public class HardAppCreateDailyMenuReserveOrdersDto {
|
||||
private String payType;
|
||||
|
||||
/**
|
||||
* 付款人本次选择的钱包类型 ID,服务端会按当前登录用户再次校验钱包归属和余额。
|
||||
* 旧客户端兼容字段。服务端不再使用该值选择钱包,实际扣款钱包固定按每位用餐人的 EAT 饭卡解析;
|
||||
* 新客户端可以不传,旧客户端继续传入也不会改变结算目标。
|
||||
*/
|
||||
@NotNull(message = "钱包类型不能为空")
|
||||
private Long walletTypeId;
|
||||
|
||||
/**
|
||||
|
||||
+7
@@ -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<Long> dinerUserIds;
|
||||
|
||||
/**
|
||||
* App 支付平台类型。新客户端固定提交钱包支付编码 30;旧客户端未传时按 30 兼容处理,其他编码拒绝进入饭卡直扣。
|
||||
*/
|
||||
@Pattern(regexp = "30", message = "批量订餐暂仅支持钱包支付")
|
||||
private String payType;
|
||||
|
||||
private Long apptId;
|
||||
|
||||
private Long apptRuleId;
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ public class HardAppRefundApplyDto {
|
||||
/**
|
||||
* 用户填写的问题描述,便于商户审核本次所选商品。
|
||||
*/
|
||||
@NotBlank(message = "相关描述不能为空")
|
||||
// @NotBlank(message = "相关描述不能为空")
|
||||
@Schema(description = "相关描述")
|
||||
private String applyDescription;
|
||||
|
||||
|
||||
+141
@@ -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<HardAutoRefundAttemptLogEntity> {
|
||||
|
||||
/**
|
||||
* 日志主键,使用 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;
|
||||
}
|
||||
+159
@@ -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<HardAutoRefundTaskEntity> {
|
||||
|
||||
/**
|
||||
* 任务主键,使用 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;
|
||||
}
|
||||
+18
@@ -141,6 +141,24 @@ public class HardOrderEntity extends Model<HardOrderEntity> {
|
||||
@Schema(description = "预计使用日期,食堂预约订单取预约目标日期,其他订单取下单当天,精确到天")
|
||||
private LocalDate apptUseDate;
|
||||
|
||||
/**
|
||||
* 下单时命中的商户自动退款配置主键快照;为空表示订单不具备免审资格。
|
||||
*/
|
||||
@Schema(description = "自动退款配置主键快照")
|
||||
private Long autoRefundConfigId;
|
||||
|
||||
/**
|
||||
* 下单时保存的自动退款规则名称;退款申请时不重新按新规则计算截止时间。
|
||||
*/
|
||||
@Schema(description = "自动退款规则快照")
|
||||
private String autoRefundRuleType;
|
||||
|
||||
/**
|
||||
* 免审退款申请截止时间,使用服务器默认时区并按 {@code now <= deadline} 判断。
|
||||
*/
|
||||
@Schema(description = "自动退款申请截止时间")
|
||||
private LocalDateTime autoRefundDeadline;
|
||||
|
||||
/**
|
||||
* 订单设备类型
|
||||
*/
|
||||
|
||||
+5
@@ -10,6 +10,11 @@ import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserInterface, BaseDeptInterface, BaseOrderInterface {
|
||||
/**
|
||||
* 自动退款任务状态;为空表示当前售后仍走人工审核,非空时前端不得展示审核或取消操作。
|
||||
*/
|
||||
private String autoRefundTaskStatus;
|
||||
|
||||
/**
|
||||
* 用户相关
|
||||
*/
|
||||
|
||||
+45
@@ -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;
|
||||
}
|
||||
+44
@@ -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;
|
||||
}
|
||||
+36
@@ -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 聚合后的退款商品列表。
|
||||
*/
|
||||
|
||||
+155
@@ -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<HardAutoRefundTaskVo>> page(@ParameterObject Page<HardAutoRefundTaskEntity> page,
|
||||
@ParameterObject HardAutoRefundTaskPageDto dto) {
|
||||
List<Long> scopedMchIds = listScopedMerchantIds();
|
||||
if (scopedMchIds.isEmpty()) {
|
||||
Page<HardAutoRefundTaskVo> empty = new Page<>(page.getCurrent(), page.getSize(), 0L);
|
||||
empty.setRecords(List.of());
|
||||
return R.ok(empty);
|
||||
}
|
||||
dto.setMchIds(scopedMchIds);
|
||||
return R.ok(taskService.pageTasks(page, dto));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定任务全部只读尝试日志。
|
||||
*
|
||||
* @param id 任务主键
|
||||
* @return 按尝试序号升序的日志列表
|
||||
*/
|
||||
@GetMapping("/{id}/attempts")
|
||||
@Operation(summary = "查询自动退款尝试日志")
|
||||
@PreAuthorize("@pms.hasPermission('hard_hardAfterSale_view')")
|
||||
public R<List<HardAutoRefundAttemptLogEntity>> 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<Boolean> 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<HardAppAutoRefundTaskVo> 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<Long> listScopedMerchantIds() {
|
||||
MchManageDto query = MchManageDto.builder().typesList(List.of(MerchantTypeEnum.CANTEEN.getType())).build();
|
||||
R<List<MchManageVo>> 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();
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+57
@@ -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<HardAutoRefundTaskEntity> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -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<HardAutoRefundAttemptLogEntity> {
|
||||
}
|
||||
+51
@@ -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<HardAutoRefundTaskEntity> {
|
||||
|
||||
/**
|
||||
* 按主键锁定任务行,供状态复查、尝试计数和最终状态更新使用。
|
||||
*
|
||||
* @param id 任务主键
|
||||
* @return 已锁定任务;不存在或已逻辑删除时返回 {@code null}
|
||||
*/
|
||||
default HardAutoRefundTaskEntity selectByIdForUpdate(Long id) {
|
||||
return selectOne(new LambdaQueryWrapper<HardAutoRefundTaskEntity>()
|
||||
.eq(HardAutoRefundTaskEntity::getId, id)
|
||||
.last("FOR UPDATE"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按管理筛选和服务端商户数据范围分页查询任务。
|
||||
*
|
||||
* @param page 分页参数
|
||||
* @param dto 筛选条件,mchIds 由服务端注入
|
||||
* @return 自动退款任务分页
|
||||
*/
|
||||
default Page<HardAutoRefundTaskEntity> pageTasks(Page<HardAutoRefundTaskEntity> page, HardAutoRefundTaskPageDto dto) {
|
||||
LambdaQueryWrapper<HardAutoRefundTaskEntity> wrapper = new LambdaQueryWrapper<HardAutoRefundTaskEntity>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
+8
@@ -64,6 +64,14 @@ public interface HardAfterSaleService extends IService<HardAfterSaleEntity> {
|
||||
|
||||
void refund(Long afterSaleId, String prefixStr);
|
||||
|
||||
/**
|
||||
* 由自动退款任务在已持有订单锁的事务中完成钱包退款、售后状态、库存和订单聚合更新。
|
||||
*
|
||||
* @param afterSaleId 自动退款任务绑定的售后单 ID
|
||||
* @param autoRefundConfigId 下单快照中的自动退款配置 ID,Wallet 据此执行最终开关行锁校验
|
||||
*/
|
||||
void completeAutoRefund(Long afterSaleId, Long autoRefundConfigId);
|
||||
|
||||
/**
|
||||
* 同意售后
|
||||
*/
|
||||
|
||||
+12
-1
@@ -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) {
|
||||
}
|
||||
}
|
||||
+110
@@ -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<HardAutoRefundTaskEntity> {
|
||||
|
||||
/**
|
||||
* 在售后创建事务中根据订单快照、截止时间和当前配置尝试创建唯一任务。
|
||||
*
|
||||
* @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<HardAutoRefundTaskVo> pageTasks(Page<HardAutoRefundTaskEntity> page, HardAutoRefundTaskPageDto dto);
|
||||
|
||||
/**
|
||||
* 查询任务的只读尝试日志,按尝试序号升序返回。
|
||||
*
|
||||
* @param taskId 任务主键
|
||||
* @return 尝试日志列表
|
||||
*/
|
||||
List<HardAutoRefundAttemptLogEntity> 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<HardAutoRefundTaskEntity> listScanCandidates(int limit);
|
||||
}
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+153
-6
@@ -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<HardAfterSaleMapper, H
|
||||
private final RemoteUserService userService;
|
||||
private final RemoteDeptService deptService;
|
||||
private final RemoteWalletRechargeService walletRechargeService;
|
||||
/** 钱包查询服务仅用于核对历史订单的钱包所有人,退款资金目标仍以订单 walletId 为准。 */
|
||||
private final RemoteWalletService walletService;
|
||||
private final RemoterThirdAppPlusPayService thirdAppPlusPayService;
|
||||
private final HardCarService hardCarService;
|
||||
private final HardAfterSaleCarService hardAfterSaleCarService;
|
||||
|
||||
/** 自动退款任务服务,用于申请受理、旧入口强制拦截和售后响应状态补充。 */
|
||||
private final HardAutoRefundTaskService hardAutoRefundTaskService;
|
||||
|
||||
/** 退款订单级分布式锁模板,与数据库订单行锁共同保护微服务和单体部署。 */
|
||||
private final LockTemplate lockTemplate;
|
||||
|
||||
@@ -135,6 +142,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
|
||||
List<HardOrderEntity> 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<HardAfterSaleMapper, H
|
||||
DataUtil.convertDeptList(list.getRecords(), deptList.getData());
|
||||
}
|
||||
}
|
||||
fillAutoRefundTaskStatus(list.getRecords());
|
||||
}
|
||||
return list;
|
||||
}
|
||||
@@ -238,6 +247,10 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
|
||||
insertAfterSaleLog(afterSale.getId(), userId, HardAfterSaleOperateTypeEnum.MEMBER_CREATE.getType(),
|
||||
order.getOrderStatus(), HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.APPLY.getContent());
|
||||
recomputeOrderRefundState(order.getId());
|
||||
HardAutoRefundTaskEntity task = hardAutoRefundTaskService.tryCreateTask(order, afterSale);
|
||||
if (task != null) {
|
||||
hardAutoRefundTaskService.executeFirstAfterCommit(task.getId());
|
||||
}
|
||||
return afterSale.getId();
|
||||
}
|
||||
|
||||
@@ -268,6 +281,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
|
||||
|
||||
HardAppRefundApplyVo detail = new HardAppRefundApplyVo();
|
||||
detail.setOrderId(orderId);
|
||||
detail.setRefundToDinerWallet(isDinerWalletReserveOrder(order));
|
||||
detail.setOrderPayPrice(safePrice(order.getPayPrice()));
|
||||
detail.setCompletedRefundPrice(sumRefundPrice(afterSales, HardSaleStatusEnum.COMPLETE.getStatus()));
|
||||
detail.setPendingRefundPrice(sumRefundPrice(afterSales, HardSaleStatusEnum.APPLY.getStatus()));
|
||||
@@ -275,6 +289,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
|
||||
detail.setHasPending(detail.getPendingRefundPrice() > 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<HardAfterSaleMapper, H
|
||||
detail.setLatestCreateTime(latest.getCreateTime());
|
||||
detail.setLatestUpdateTime(latest.getUpdateTime());
|
||||
}
|
||||
detail.setAutoRefundRuleType(order.getAutoRefundRuleType());
|
||||
detail.setAutoRefundDeadline(order.getAutoRefundDeadline());
|
||||
detail.setAutoRefundTaskStatus(latestTask == null ? null : latestTask.getStatus());
|
||||
detail.setAutoRefundEligible(hardAutoRefundTaskService.isEligibleNow(order));
|
||||
detail.setLatestAfterSaleCancelable(latest != null
|
||||
&& ObjectUtil.equals(latest.getStatus(), HardSaleStatusEnum.APPLY.getStatus())
|
||||
&& latestTask == null);
|
||||
|
||||
List<HardAppRefundProductVo> products = allocations.values().stream().map(allocation -> {
|
||||
HardAppRefundProductVo product = new HardAppRefundProductVo();
|
||||
@@ -306,6 +328,31 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断退款是否应提示退回实际用餐人的饭卡。订单钱包 ID 是退款服务的唯一资金目标,客户端不得传入替代钱包。
|
||||
*
|
||||
* @param order 待申请退款的原订单
|
||||
* @return 食堂预约且由默认钱包支付,并且订单钱包确属实际用餐人时返回 {@code true}
|
||||
*/
|
||||
private boolean isDinerWalletReserveOrder(HardOrderEntity order) {
|
||||
if (!Objects.equals(order.getSysType(), HardTypeEnum.CANTEEN.getType())
|
||||
|| !Objects.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())
|
||||
|| !Objects.equals(order.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType())
|
||||
|| order.getWalletId() == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
R<WalletManageVo> 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<HardAfterSaleMapper, H
|
||||
if (!ObjectUtil.equals(HardSaleStatusEnum.APPLY.getStatus(), afterSale.getStatus())) {
|
||||
throw new RuntimeException("取消售后单失败,售后单状态不是【待审核】");
|
||||
}
|
||||
ensureNoAutoRefundTask(afterSaleId);
|
||||
// 更新售后单的状态为【已取消】
|
||||
HardAfterSaleEntity updateRefundObj = HardAfterSaleEntity.builder().id(afterSale.getId()).status(HardSaleStatusEnum.BUYER_CANCEL.getStatus()).build();
|
||||
baseMapper.updateById(updateRefundObj);
|
||||
@@ -426,6 +474,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
|
||||
DataUtil.convertDept(detail, dept.getData());
|
||||
}
|
||||
}
|
||||
fillAutoRefundTaskStatus(List.of(detail));
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
@@ -457,15 +506,26 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void refund(Long afterSaleId, String prefixStr) {
|
||||
HardOrderEntity order = refundInCurrentTransaction(afterSaleId, prefixStr, null);
|
||||
recomputeOrderRefundState(order.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前 Hardware 事务中调用原支付渠道退款。自动流程携带配置 ID,人工流程保持为空。
|
||||
*
|
||||
* @param afterSaleId 待执行的售后单 ID
|
||||
* @param prefixStr 退款业务号前缀,为空时使用 {@code hard}
|
||||
* @param autoRefundConfigId 自动退款配置 ID;人工审核退款为空
|
||||
* @return 已锁定并完成渠道退款的原订单
|
||||
*/
|
||||
private HardOrderEntity refundInCurrentTransaction(Long afterSaleId, String prefixStr, Long autoRefundConfigId) {
|
||||
HardAfterSaleEntity afterSale = baseMapper.selectById(afterSaleId);
|
||||
if (ObjectUtil.isEmpty(afterSale)) throw new RuntimeException("售后订单不存在");
|
||||
HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(afterSale.getOrderId());
|
||||
if (order == null) throw new RuntimeException("订单不存在");
|
||||
validateOrderTypeRefundable(order);
|
||||
if (afterSale.getRefundTime() != null) {
|
||||
recomputeOrderRefundState(order.getId());
|
||||
return;
|
||||
}
|
||||
// hard_after_sale.refund_time 的历史表定义带 CURRENT_TIMESTAMP 默认值,申请创建时就可能非空,
|
||||
// 不能用它判断渠道退款是否执行过;重复调用由售后状态、订单锁和售后单 ID 下游幂等共同兜底。
|
||||
// 生成退款单号
|
||||
if (ObjectUtil.isEmpty(prefixStr)) prefixStr = "hard";
|
||||
String refundCode = prefixStr + "_" + order.getId() + "_" + afterSale.getId();
|
||||
@@ -501,9 +561,11 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
|
||||
payOrderBizType = ThirdPayOrderBizTypeEnum.APP_SHOP_PAY;
|
||||
}
|
||||
if (isWalletPay) {
|
||||
WalletRechargeRefundDto hardRefundDto = WalletRechargeRefundDto.builder().walletId(order.getWalletId()).orderId(order.getId()).afterSaleId(afterSale.getId()).refundCode(refundCode).reason("订单退款").price(refundPrice).build();
|
||||
WalletRechargeRefundDto hardRefundDto = WalletRechargeRefundDto.builder().walletId(order.getWalletId())
|
||||
.orderId(order.getId()).afterSaleId(afterSale.getId()).refundCode(refundCode).reason("订单退款")
|
||||
.price(refundPrice).autoRefundConfigId(autoRefundConfigId).build();
|
||||
R<WalletRechargeVo> 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<HardAfterSaleMapper, H
|
||||
throw new RuntimeException("不支持的退款类型");
|
||||
}
|
||||
baseMapper.updateById(HardAfterSaleEntity.builder().id(afterSaleId).refundTime(LocalDateTime.now()).build());
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动任务免审完成钱包退款。只有状态为申请中且订单快照配置一致的预约钱包订单允许进入。
|
||||
*
|
||||
* @param afterSaleId 自动退款任务绑定的售后单 ID
|
||||
* @param autoRefundConfigId 下单时保存的自动退款配置 ID
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void completeAutoRefund(Long afterSaleId, Long autoRefundConfigId) {
|
||||
HardAfterSaleEntity afterSale = baseMapper.selectById(afterSaleId);
|
||||
if (afterSale == null) {
|
||||
throw new RuntimeException("售后单不存在");
|
||||
}
|
||||
if (!ObjectUtil.equals(afterSale.getStatus(), HardSaleStatusEnum.APPLY.getStatus())) {
|
||||
throw new RuntimeException("自动退款售后状态不处于申请中");
|
||||
}
|
||||
HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(afterSale.getOrderId());
|
||||
if (order == null) {
|
||||
throw new RuntimeException("订单不存在");
|
||||
}
|
||||
if (!Objects.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())
|
||||
|| !Objects.equals(order.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType())
|
||||
|| !Objects.equals(order.getAutoRefundConfigId(), autoRefundConfigId)) {
|
||||
throw new RuntimeException("订单自动退款快照不匹配");
|
||||
}
|
||||
|
||||
refundInCurrentTransaction(afterSaleId, "hard", autoRefundConfigId);
|
||||
LocalDateTime completedTime = LocalDateTime.now();
|
||||
HardAfterSaleEntity completed = HardAfterSaleEntity.builder().id(afterSaleId)
|
||||
.status(HardSaleStatusEnum.COMPLETE.getStatus())
|
||||
.auditTime(completedTime)
|
||||
.refundTime(completedTime)
|
||||
.build();
|
||||
if (baseMapper.updateById(completed) != 1) {
|
||||
throw new RuntimeException("更新自动退款售后状态失败");
|
||||
}
|
||||
// 日志表 user_id 非空,系统动作以申请人作为关联用户,实际执行来源由独立操作类型 31 明确区分。
|
||||
insertAfterSaleLog(afterSaleId, afterSale.getUserId(), HardAfterSaleOperateTypeEnum.SYSTEM_AUTO_REFUND.getType(),
|
||||
HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus(),
|
||||
HardAfterSaleOperateTypeEnum.SYSTEM_AUTO_REFUND.getContent());
|
||||
List<HardAfterSaleCarEntity> 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<HardAfterSaleMapper, H
|
||||
.orderByAsc(HardAfterSaleEntity::getId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量补充售后单关联的自动退款任务状态,普通人工售后保持为空。
|
||||
*
|
||||
* @param records 当前售后分页或详情响应
|
||||
*/
|
||||
private void fillAutoRefundTaskStatus(List<HardAfterSaleVo> records) {
|
||||
List<Long> afterSaleIds = records.stream().map(HardAfterSaleVo::getId).filter(Objects::nonNull).distinct().toList();
|
||||
if (afterSaleIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<Long, String> taskStatusMap = hardAutoRefundTaskService.list(
|
||||
new LambdaQueryWrapper<HardAutoRefundTaskEntity>()
|
||||
.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<HardAfterSaleMapper, H
|
||||
return Math.multiplyExact((long) safePrice(car.getProductPrice()), safeCount(car.getProductCount()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验售后单仍处于人工可审核状态,并强制排除全部自动退款任务。
|
||||
*
|
||||
* @param id 待审核售后单 ID
|
||||
* @return 可进入人工审核的售后单
|
||||
*/
|
||||
private HardAfterSaleEntity validateAfterSaleAuditable(Long id) {
|
||||
HardAfterSaleEntity afterSale = baseMapper.selectById(id);
|
||||
if (afterSale == null) throw new RuntimeException("售后单不存在");
|
||||
ensureNoAutoRefundTask(id);
|
||||
if (ObjectUtil.notEqual(afterSale.getStatus(), HardSaleStatusEnum.APPLY.getStatus())) {
|
||||
throw new RuntimeException("审批失败,售后状态不处于审批中");
|
||||
}
|
||||
|
||||
+972
@@ -0,0 +1,972 @@
|
||||
package com.bsd.pjcloud.hard.service.impl.order;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import com.baomidou.lock.LockInfo;
|
||||
import com.baomidou.lock.LockTemplate;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.bsd.pjcloud.admin.api.entity.SysUser;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
|
||||
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
|
||||
import com.bsd.pjcloud.common.core.util.BeanUtils;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundAttemptResultEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum;
|
||||
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 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.hard.service.HardAutoRefundTaskService;
|
||||
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.MchManageVo;
|
||||
import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 自动退款任务状态机实现。订单 Redis 锁覆盖一次完整尝试及失败落库,任务行锁负责多实例最终串行。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTaskMapper, HardAutoRefundTaskEntity>
|
||||
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<HardAfterSaleService> 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<WalletRechargeEntity> 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<MchAutoRefundConfigVo> 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<HardAutoRefundTaskVo> pageTasks(Page<HardAutoRefundTaskEntity> page, HardAutoRefundTaskPageDto dto) {
|
||||
Page<HardAutoRefundTaskEntity> entityPage = baseMapper.pageTasks(page, dto);
|
||||
List<HardAutoRefundTaskVo> records = BeanUtils.toBean(entityPage.getRecords(), HardAutoRefundTaskVo.class);
|
||||
fillAfterSaleData(records);
|
||||
fillUserData(records);
|
||||
fillMerchantData(records);
|
||||
Page<HardAutoRefundTaskVo> result = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
|
||||
result.setRecords(records);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量补充售后申请用户、核定金额和当前状态。
|
||||
*
|
||||
* @param records 当前页任务响应
|
||||
*/
|
||||
private void fillAfterSaleData(List<HardAutoRefundTaskVo> records) {
|
||||
List<Long> ids = records.stream().map(HardAutoRefundTaskVo::getAfterSaleId).filter(Objects::nonNull).distinct().toList();
|
||||
if (ids.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<Long, HardAfterSaleEntity> 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<HardAutoRefundTaskVo> records) {
|
||||
List<Long> userIds = records.stream().map(HardAutoRefundTaskVo::getUserId).filter(Objects::nonNull).distinct().toList();
|
||||
if (userIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
R<List<SysUser>> response = remoteUserService.getUserById(new ArrayList<>(userIds));
|
||||
if (response == null || !response.isOk() || response.getData() == null) {
|
||||
return;
|
||||
}
|
||||
Map<Long, SysUser> 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<HardAutoRefundTaskVo> records) {
|
||||
List<Long> mchIds = records.stream().map(HardAutoRefundTaskVo::getMchId).filter(Objects::nonNull).distinct().toList();
|
||||
if (mchIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
R<List<MchManageVo>> response = remoteMchService.getByIds(mchIds);
|
||||
if (response == null || !response.isOk() || response.getData() == null) {
|
||||
return;
|
||||
}
|
||||
Map<Long, MchManageVo> 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<HardAutoRefundAttemptLogEntity> listAttempts(Long taskId) {
|
||||
return attemptLogMapper.selectList(new LambdaQueryWrapper<HardAutoRefundAttemptLogEntity>()
|
||||
.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<HardAutoRefundTaskEntity>()
|
||||
.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<HardAutoRefundTaskEntity> listScanCandidates(int limit) {
|
||||
if (limit <= 0) {
|
||||
return List.of();
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
List<HardAutoRefundTaskEntity> result = new ArrayList<>(baseMapper.selectList(
|
||||
new LambdaQueryWrapper<HardAutoRefundTaskEntity>()
|
||||
.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<HardAutoRefundTaskEntity>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+298
-81
@@ -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<HardOrderMapper, HardOrder
|
||||
private final RemoteCanteenSpuService remoteCanteenSpuService;
|
||||
private final RemoteOnlineCanteenSpuService remoteOnlineCanteenSpuService;
|
||||
private final RemoteMchService remoteMchService;
|
||||
|
||||
/** 商户自动退款配置远程服务,每日菜谱批量下单只查询一次并写入订单快照。 */
|
||||
private final RemoteMchAutoRefundConfigService remoteMchAutoRefundConfigService;
|
||||
private final RemoteMchPickUpService remoteMchPickUpService;
|
||||
private final RemoteCodeUseService remoteCodeUseService;
|
||||
private final RemoteWalletService remoteWalletService;
|
||||
@@ -1290,13 +1295,16 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量订餐在创建前完成整批授权、余额、预约数量与购物车校验。每位用餐人独立购物车、订单和钱包流水;
|
||||
* 远程钱包或库存步骤失败时,逐笔补偿已完成的远程操作,本地订单和购物车由事务回滚。
|
||||
* 批量订餐在创建前完成整批授权、饭卡余额、预约数量与购物车校验。代订人只持有父购物车和查看权限,
|
||||
* 每位实际用餐人的订单绑定并扣减其本人 EAT 饭卡;远程钱包或库存失败时整批补偿,本地数据由事务回滚。
|
||||
*/
|
||||
@Override
|
||||
@Lock4j(keys = {"#dto.carParentId"})
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public HardAppCreateReserveOrdersVo appCreateReserveOrders(Long payerUserId, @Valid HardAppCreateReserveOrdersDto dto) {
|
||||
if (StrUtil.isNotBlank(dto.getPayType()) && !Objects.equals(dto.getPayType(), "30")) {
|
||||
throw new RuntimeException("批量订餐暂仅支持钱包支付");
|
||||
}
|
||||
List<Long> 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<HardOrderMapper, HardOrder
|
||||
throw new RuntimeException("购物车商户不是食堂商户");
|
||||
}
|
||||
validateMerchantReserveDinerPermissions(payerUserId, dinerUserIds, merchant);
|
||||
WalletManageEntity wallet = Optional.ofNullable(remoteWalletService.getWallet(payerUserId, WalletTypeEnum.EAT)).map(R::getData).orElseThrow(() -> new RuntimeException("用户钱包不存在"));
|
||||
if (!Objects.equals(wallet.getUserId(), payerUserId)) {
|
||||
throw new RuntimeException("钱包不属于当前付款人");
|
||||
}
|
||||
List<SysUser> dinerUsers = Optional.ofNullable(userService.getInnerUserById(dinerUserIds)).map(R::getData).orElseThrow(() -> new RuntimeException("查询用餐人失败"));
|
||||
Map<Long, SysUser> 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<HardOrderMapper, HardOrder
|
||||
throw new RuntimeException("购物车金额异常");
|
||||
}
|
||||
int totalPayPrice = Math.multiplyExact(singlePrice, dinerUserIds.size());
|
||||
if (wallet.getBalance() == null || wallet.getBalance() < totalPayPrice) {
|
||||
throw new RuntimeException("钱包余额不足");
|
||||
}
|
||||
Map<Long, WalletManageEntity> dinerWalletMap = resolveReserveDinerWallets(dinerUserIds, dinerUserMap, singlePrice);
|
||||
Map<Long, Integer> inventorySnapshot = validateReserveBatchInventory(sourceParentCar.getMerchantId(), sourceCarList, dinerUserIds.size());
|
||||
String subject = buildReserveOrderSubject(dto.getCarParentId(), sourceCarList);
|
||||
// 在所有远程预检完成后再占用餐段锁,缩短并发请求等待时间;锁仍覆盖查询、落单与事务提交。
|
||||
validateSingleReservationOnlyPerMeal(merchant, dinerUserIds, reservation);
|
||||
|
||||
List<HardOrderEntity> 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<HardOrderMapper, HardOrder
|
||||
List<DailyMenuReserveGroupContext> 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<SysUser> dinerUsers = Optional.ofNullable(userService.getInnerUserById(dinerUserIds))
|
||||
.map(R::getData)
|
||||
.orElseThrow(() -> new RuntimeException("查询用餐人失败"));
|
||||
@@ -1434,9 +1443,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
if (totalPayPrice <= 0) {
|
||||
throw new RuntimeException("购物车金额异常");
|
||||
}
|
||||
if (wallet.getBalance() == null || wallet.getBalance() < totalPayPrice) {
|
||||
throw new RuntimeException("钱包余额不足");
|
||||
}
|
||||
Map<Long, WalletManageEntity> dinerWalletMap = resolveReserveDinerWallets(dinerUserIds, dinerUserMap, singleDinerTotalPrice);
|
||||
|
||||
// 固定按日期和餐段顺序获取冲突锁,避免两个批量请求以相反分组顺序互相等待。
|
||||
contexts.stream()
|
||||
@@ -1454,19 +1461,19 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
? context.parentCar().getId()
|
||||
: cloneReserveCart(context.parentCar(), context.carList(), payerUserId);
|
||||
HardOrderEntity order = buildReserveOrder(dinerUserMap.get(dinerUserId), parentCarId, merchant,
|
||||
wallet, context.reservation(), context.singlePrice(), subject);
|
||||
dinerWalletMap.get(dinerUserId), context.reservation(), context.singlePrice(), subject);
|
||||
applyDailyMenuAutoRefundSnapshot(order, context.reservation(), autoRefundConfig);
|
||||
if (baseMapper.insert(order) != 1) {
|
||||
throw new RuntimeException("创建订餐订单失败");
|
||||
}
|
||||
createdOrders.add(order);
|
||||
}
|
||||
}
|
||||
for (HardOrderEntity order : createdOrders) {
|
||||
reduceReserveOrderBalance(order);
|
||||
markReserveOrderPaid(order);
|
||||
}
|
||||
// 方法体成功后仍可能在数据库提交阶段失败,回滚回调负责补偿已经完成的跨服务钱包扣款。
|
||||
registerDailyMenuWalletRollbackCompensation(createdOrders);
|
||||
reduceReserveOrderBalances(createdOrders);
|
||||
for (HardOrderEntity order : createdOrders) {
|
||||
markReserveOrderPaid(order, autoRefundConfig);
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
compensateReserveOrderWallets(createdOrders);
|
||||
throw exception;
|
||||
@@ -1484,7 +1491,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
* 将每日菜谱钱包补偿绑定到本地事务回滚,覆盖方法返回后发生的数据库提交失败。
|
||||
* 钱包补偿接口按订单业务号幂等,方法体异常路径即使已经补偿,再次调用也不会重复入账。
|
||||
*
|
||||
* @param createdOrders 已完成远程钱包扣款、等待本地事务提交的订单
|
||||
* @param createdOrders 已分配订单 ID、即将进行远程钱包扣款的订单;未产生 PAYMENT 流水时补偿接口会安全忽略
|
||||
*/
|
||||
private void registerDailyMenuWalletRollbackCompensation(List<HardOrderEntity> createdOrders) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
@@ -1506,6 +1513,42 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将旧线上订餐的钱包与库存补偿绑定到本地事务回滚,覆盖方法体成功但数据库提交失败的窗口。
|
||||
* 远程补偿均使用稳定业务号并可重复调用,因此方法体异常已补偿时回调再次执行也不会重复退款或加库存。
|
||||
*
|
||||
* @param createdOrders 已分配订单 ID、即将进行远程库存和钱包操作的完整订单批次
|
||||
* @param sourceCarList 原始购物车商品,用于构造库存批次业务号和恢复明细
|
||||
* @param dinerCount 本批次实际用餐人数
|
||||
* @param inventorySnapshot 扣库存前的在线商品库存快照
|
||||
* @param inventoryBizId 本次订单批次唯一库存业务号,扣减与恢复必须完全一致
|
||||
*/
|
||||
private void registerReserveOrderRollbackCompensation(List<HardOrderEntity> createdOrders,
|
||||
List<HardCarEntity> sourceCarList,
|
||||
int dinerCount,
|
||||
Map<Long, Integer> inventorySnapshot,
|
||||
String inventoryBizId) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
throw new RuntimeException("订餐事务状态异常,请稍后重试");
|
||||
}
|
||||
List<HardOrderEntity> rollbackOrders = List.copyOf(createdOrders);
|
||||
List<HardCarEntity> rollbackCars = List.copyOf(sourceCarList);
|
||||
Map<Long, Integer> 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<HardOrderMapper, HardOrder
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前付款人和支付抽屉选择的钱包类型查询钱包,拒绝空响应、越权钱包和异常余额。
|
||||
* 每日菜谱批量下单只查询一次预约订单自动退款配置。任何远程、开关或规则异常均降级为无快照正常下单。
|
||||
*
|
||||
* @param payerUserId 当前登录付款人 ID
|
||||
* @param walletTypeId 支付抽屉选择的钱包类型 ID
|
||||
* @return 归属于当前付款人的可用钱包
|
||||
* @param merchantId 每日菜谱所属商户 ID
|
||||
* @return 当前开启且规则有效的配置;不具备快照条件时返回 {@code null}
|
||||
*/
|
||||
private WalletManageEntity getDailyMenuPaymentWallet(Long payerUserId, Long walletTypeId) {
|
||||
R<WalletManageEntity> 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<MchAutoRefundConfigVo> 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<Long, WalletManageEntity> resolveReserveDinerWallets(List<Long> dinerUserIds,
|
||||
Map<Long, SysUser> dinerUserMap,
|
||||
int requiredPricePerDiner) {
|
||||
if (requiredPricePerDiner <= 0) {
|
||||
throw new RuntimeException("订餐金额异常");
|
||||
}
|
||||
R<List<WalletManageEntity>> 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<Long, WalletManageEntity> 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<HardOrderMapper, HardOrder
|
||||
}
|
||||
|
||||
/**
|
||||
* 多人订餐扣款不允许透支,余额预检后仍通过钱包原子条件更新防止跨业务并发超扣。
|
||||
* 将全部订单一次提交给钱包服务,钱包服务在单个事务内锁定各用餐人饭卡、汇总校验并逐订单记录支付流水。
|
||||
*
|
||||
* @param orders 已分配订单 ID 且写入实际用餐人钱包 ID 的完整批次
|
||||
*/
|
||||
private void reduceReserveOrderBalance(HardOrderEntity order) {
|
||||
WalletBalanceTransactionDto reduceDto = new WalletBalanceTransactionDto();
|
||||
reduceDto.setWalletId(order.getWalletId());
|
||||
reduceDto.setPrice(order.getPayPrice());
|
||||
reduceDto.setBizId(String.valueOf(order.getId()));
|
||||
reduceDto.setBizType(WalletBizEnum.PAYMENT);
|
||||
reduceDto.setForce(false);
|
||||
R<WalletTransactionEntity> result = walletTransactionService.reduceWalletBalanceIdempotent(reduceDto);
|
||||
if (result == null || !result.isOk() || result.getData() == null) {
|
||||
throw new RuntimeException(result == null ? "钱包扣款无响应" : result.getMsg());
|
||||
private void reduceReserveOrderBalances(List<HardOrderEntity> orders) {
|
||||
List<WalletBalanceTransactionDto> paymentList = buildReserveWalletTransactions(orders);
|
||||
R<List<WalletTransactionEntity>> 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<WalletBalanceTransactionDto> buildReserveWalletTransactions(List<HardOrderEntity> 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<HardOrderMapper, HardOrder
|
||||
return targetParentCar.getId();
|
||||
}
|
||||
|
||||
private HardOrderEntity buildReserveOrder(SysUser dinerUser, Long parentCarId, MchManageVo merchant, WalletManageEntity wallet, HardApptReservationService.ReservationValidation reservation, int singlePrice, String subject) {
|
||||
/**
|
||||
* 构造单个实际用餐人的预约订单。订单用户和钱包所有人必须一致,父购物车仍归代订人所有以保留查看与退款权限。
|
||||
*
|
||||
* @param dinerUser 实际用餐人
|
||||
* @param parentCarId 代订人名下对应的独立父购物车 ID
|
||||
* @param merchant 食堂商户
|
||||
* @param dinerWallet 实际用餐人本人的 EAT 饭卡
|
||||
* @param reservation 服务端校验后的预约上下文
|
||||
* @param singlePrice 当前日期餐段应付金额,单位为分
|
||||
* @param subject 商品摘要
|
||||
* @return 尚未标记支付完成的预约订单
|
||||
*/
|
||||
private HardOrderEntity buildReserveOrder(SysUser dinerUser, Long parentCarId, MchManageVo merchant, WalletManageEntity dinerWallet,
|
||||
HardApptReservationService.ReservationValidation reservation, int singlePrice, String subject) {
|
||||
if (dinerUser == null || dinerWallet == null || !Objects.equals(dinerUser.getUserId(), dinerWallet.getUserId())) {
|
||||
throw new RuntimeException("用餐人与饭卡归属不一致");
|
||||
}
|
||||
HardOrderEntity order = HardOrderEntity.builder()
|
||||
.userId(dinerUser.getUserId())
|
||||
.userDeptId(dinerUser.getDeptId())
|
||||
@@ -2051,7 +2235,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
.totalPrice(singlePrice)
|
||||
.extraPrice(0)
|
||||
.payPrice(singlePrice)
|
||||
.walletId(wallet.getId())
|
||||
.walletId(dinerWallet.getId())
|
||||
.isOfflineOrder(YesNoEnum.NO.getCode())
|
||||
.apptUseDate(reservation.apptDate())
|
||||
.productDesc(subject)
|
||||
@@ -2066,11 +2250,30 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
* 扣款完成后必须同时更新订单和对应父购物车;任一更新失败都进入整批补偿并回滚本地事务。
|
||||
*/
|
||||
private void markReserveOrderPaid(HardOrderEntity order) {
|
||||
markReserveOrderPaid(order, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扣款完成后更新订单和父购物车;支付限时规则以同一个 payTime 计算截止时间并持久化完整快照。
|
||||
*
|
||||
* @param order 已完成钱包扣款的预约订单
|
||||
* @param autoRefundConfig 每日菜谱批次配置;旧预约下单或无有效配置时为空
|
||||
*/
|
||||
private void markReserveOrderPaid(HardOrderEntity order, MchAutoRefundConfigVo autoRefundConfig) {
|
||||
LocalDateTime payTime = LocalDateTime.now();
|
||||
LocalDateTime autoRefundDeadline = order.getAutoRefundDeadline();
|
||||
if (autoRefundConfig != null
|
||||
&& Objects.equals(order.getAutoRefundConfigId(), autoRefundConfig.getId())
|
||||
&& MchAutoRefundRuleTypeEnum.PAY_TIME_LIMIT.name().equals(order.getAutoRefundRuleType())
|
||||
&& autoRefundConfig.getPayTimeLimitHours() != null) {
|
||||
autoRefundDeadline = payTime.plusHours(autoRefundConfig.getPayTimeLimitHours());
|
||||
}
|
||||
HardOrderEntity orderUpdate = HardOrderEntity.builder()
|
||||
.id(order.getId())
|
||||
.payTime(LocalDateTime.now())
|
||||
.payTime(payTime)
|
||||
.orderStatus(HardOrderStatusEnum.PAY.getType())
|
||||
.payPrice(order.getTotalPrice() + order.getExtraPrice())
|
||||
.autoRefundDeadline(autoRefundDeadline)
|
||||
.build();
|
||||
if (baseMapper.updateById(orderUpdate) != 1) {
|
||||
throw new RuntimeException("更新订餐订单支付状态失败");
|
||||
@@ -2082,16 +2285,20 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
if (!hardParentCarService.updateById(parentCarUpdate)) {
|
||||
throw new RuntimeException("更新订餐购物车状态失败");
|
||||
}
|
||||
order.setPayTime(payTime);
|
||||
order.setOrderStatus(HardOrderStatusEnum.PAY.getType());
|
||||
order.setAutoRefundDeadline(autoRefundDeadline);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地事务不能回滚跨服务扣款和库存,失败时按完成顺序的逆序逐笔补偿。
|
||||
* 本地事务不能回滚跨服务库存,使用同一库存业务号在食堂服务执行幂等扣减或恢复。
|
||||
*/
|
||||
private void updateReserveBatchInventory(List<HardCarEntity> sourceCarList, int dinerCount, boolean reduce, Map<Long, Integer> inventorySnapshot) {
|
||||
private void updateReserveBatchInventory(List<HardCarEntity> sourceCarList, int dinerCount, boolean reduce,
|
||||
Map<Long, Integer> 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<Long, Integer> stockChanges = sourceCarList.stream().collect(Collectors.toMap(
|
||||
HardCarEntity::getProductId,
|
||||
@@ -2112,22 +2319,38 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一批订单的库存扣减和恢复共用业务号,供食堂服务处理远程超时后的重复请求。
|
||||
* 使用本次已经分配的首个订单 ID 构造库存批次号。同一次扣减与补偿稳定复用该值,失败重试因订单 ID 不同而隔离,
|
||||
* 避免父购物车复用时命中上一次 ROLLED_BACK 状态而跳过新的库存扣减。
|
||||
*
|
||||
* @param createdOrders 已完整创建且分配 ID 的本次订单批次
|
||||
* @return 仅属于本次批次的库存幂等业务号
|
||||
*/
|
||||
private String buildReserveInventoryBizId(List<HardCarEntity> sourceCarList) {
|
||||
Long sourceParentCarId = sourceCarList.stream()
|
||||
.map(HardCarEntity::getParentCarId)
|
||||
private String buildReserveInventoryBizId(List<HardOrderEntity> 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<HardOrderEntity> createdOrders, List<HardCarEntity> sourceCarList, int dinerCount, Map<Long, Integer> 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<HardOrderEntity> createdOrders, List<HardCarEntity> sourceCarList,
|
||||
int dinerCount, Map<Long, Integer> 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<HardOrderMapper, HardOrder
|
||||
* @param createdOrders 当前事务内已经分配订单 ID 的预约订单
|
||||
*/
|
||||
private void compensateReserveOrderWallets(List<HardOrderEntity> createdOrders) {
|
||||
// 对所有已创建订单发起幂等补偿;钱包服务只会退回实际存在的 PAYMENT 流水。
|
||||
ListIterator<HardOrderEntity> 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<Boolean> 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<Boolean> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+62
@@ -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());
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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",
|
||||
|
||||
+180
@@ -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<HardAfterSaleService> 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<HardAutoRefundAttemptLogEntity> 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();
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -146,6 +146,13 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Sentinel BOM 会把 mockito-core 降至 2.28.2,此处与 Spring Boot 3.3.6 的 Mockito 5 测试套件保持一致。 -->
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<version>5.11.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!--引入分布式事务-->
|
||||
<dependency>
|
||||
<groupId>com.bsd</groupId>
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+13
-5
@@ -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;
|
||||
|
||||
/**
|
||||
* 学校名称
|
||||
*/
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
+2
-2
@@ -421,7 +421,7 @@ public class SysUserController {
|
||||
* @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度
|
||||
*/
|
||||
@PostMapping("/import")
|
||||
@HasPermission("sys_user_export")
|
||||
@HasPermission("sys_user_import")
|
||||
public R<?> importUser(@RequestExcel List<UserExcelVO> 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<UserExcelFaceVo> excelList, BindingResult bindingResult) {
|
||||
return userImportExportService.importUserFace(file, excelList, bindingResult);
|
||||
|
||||
+20
-3
@@ -7,15 +7,23 @@ import com.bsd.pjcloud.admin.api.vo.UserVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户身份及其学生、老师扩展资料的业务服务。
|
||||
*/
|
||||
public interface SysUserIdentityService extends IService<SysUserIdentityEntity> {
|
||||
|
||||
/**
|
||||
* 校验并规范化用户身份请求参数
|
||||
* 校验并规范化用户身份请求参数。
|
||||
*
|
||||
* @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<SysUserIdentityEntity>
|
||||
void deleteUserIdentityByUserIds(List<Long> userIds);
|
||||
|
||||
/**
|
||||
* 填充用户身份及对应扩展信息
|
||||
* 填充单个用户的身份及对应扩展信息。
|
||||
*
|
||||
* @param userVo 已查询到基础数据的用户展示对象
|
||||
*/
|
||||
void fillUserIdentity(UserVO userVo);
|
||||
|
||||
/**
|
||||
* 批量填充用户身份及对应扩展信息,供分页列表避免逐用户查询。
|
||||
*
|
||||
* @param userVos 已查询到基础数据的用户展示对象列表
|
||||
*/
|
||||
void fillUserIdentities(List<UserVO> userVos);
|
||||
}
|
||||
+9
-3
@@ -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<String> guardianPhones = studentUserExcelList.stream().map(StudentUserExcelVO::getGuardianPhone)
|
||||
.filter(StrUtil::isNotBlank).distinct().toList();
|
||||
Map<String, SysUser> 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());
|
||||
|
||||
+70
-8
@@ -192,6 +192,12 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> 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<SysUserMapper, SysUser> impl
|
||||
BeanUtils.copyProperties(userDto, sysUser);
|
||||
// 是否进入审批由当前操作人的专用角色控制,不能再通过部门管理员身份推断。
|
||||
PjcloudUser applyUser = SecurityUtils.getUser();
|
||||
List<SysRole> 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<SysRole> 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<SysRole> 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<SysUserMapper, SysUser> impl
|
||||
return sysUser.getUserId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询非管理员新增用户时必须赋予的固定角色ID。
|
||||
*
|
||||
* @return 按普通用户、自动创建钱包顺序排列的角色ID
|
||||
* @throws IllegalStateException 任一固定角色未在当前租户中配置时抛出
|
||||
*/
|
||||
private List<Long> getNonAdminNewUserRoleIds() {
|
||||
List<String> roleCodes = List.of(CommonRoleConstant.DEFAULT_USER, CommonRoleConstant.AUTO_CREATE_WALLET);
|
||||
List<SysRole> defaultRoles = Optional.ofNullable(sysRoleService.findRolesByCodes(roleCodes)).orElseGet(Collections::emptyList);
|
||||
Map<String, Long> 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<SysUserMapper, SysUser> impl
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询用户信息(含有角色信息)
|
||||
* 分页查询用户信息,并批量补齐角色、人脸、身份及身份扩展资料。
|
||||
*
|
||||
* @param page 分页参数,包含当前页和每页数量
|
||||
* @param userDTO 用户筛选条件及是否查询审批状态的控制参数
|
||||
* @param dataScope 当前调用人的数据权限范围;为空时不附加数据范围限制
|
||||
* @return 含角色、人脸、身份和审批信息的用户分页结果
|
||||
*/
|
||||
@Override
|
||||
public IPage<UserVO> getUsersWithRolePage(Page page, UserDTO userDTO, DataScope dataScope) {
|
||||
@@ -309,6 +336,8 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
|
||||
IPage<UserVO> list = baseMapper.getUserVosPage(page, userDTO, dataScope);
|
||||
if (ObjectUtil.isNotEmpty(list.getRecords())) {
|
||||
List<Long> userIds = list.getRecords().stream().map(UserVO::getUserId).distinct().toList();
|
||||
// 分页结果统一补齐身份及扩展资料,保证管理端列表与详情接口使用同一套身份语义。
|
||||
sysUserIdentityService.fillUserIdentities(list.getRecords());
|
||||
if (ObjectUtil.isNotEmpty(userIds)) {
|
||||
SysUserFaceDto faceDto = new SysUserFaceDto();
|
||||
List<SysUserFaceVo> faceList = sysUserFaceMapper.groupConcatFaces(faceDto);
|
||||
@@ -477,6 +506,14 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> 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<SysUserMapper, SysUser> 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<SysUserMapper, SysUser> 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<Long> currentRoleIds = sysUserRoleMapper.selectList(Wrappers.<SysUserRole>lambdaQuery().eq(SysUserRole::getUserId, userId)).stream().map(SysUserRole::getRoleId).collect(Collectors.toSet());
|
||||
Set<Long> submittedRoleIds = new HashSet<>(userDto.getRole());
|
||||
if (!currentRoleIds.equals(submittedRoleIds)) {
|
||||
throw new RuntimeException("仅管理员可以修改用户角色");
|
||||
}
|
||||
// 非管理员提交的角色仅用于一致性校验,校验通过后也不得重写角色关系。
|
||||
userDto.setRole(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁定用户
|
||||
*
|
||||
|
||||
+51
-14
@@ -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<SysUserIdentityMappe
|
||||
if (userVo == null || userVo.getUserId() == null) {
|
||||
return;
|
||||
}
|
||||
List<String> identityTypes = list(Wrappers.<SysUserIdentityEntity>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 回填分页记录。
|
||||
* <p>固定执行至多三次批量查询,避免用户列表随分页条数产生 N+1 查询。</p>
|
||||
*
|
||||
* @param userVos 已查询到基础数据的用户展示对象列表;空列表或无有效用户 ID 时不执行查询
|
||||
*/
|
||||
@Override
|
||||
public void fillUserIdentities(List<UserVO> userVos) {
|
||||
if (CollUtil.isEmpty(userVos)) {
|
||||
return;
|
||||
}
|
||||
List<Long> userIds = userVos.stream()
|
||||
.filter(userVo -> userVo != null && userVo.getUserId() != null)
|
||||
.map(UserVO::getUserId)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (CollUtil.isEmpty(userIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Map<Long, List<String>> identityTypesByUserId = list(Wrappers.<SysUserIdentityEntity>lambdaQuery()
|
||||
.in(SysUserIdentityEntity::getUserId, userIds)
|
||||
.orderByAsc(SysUserIdentityEntity::getCreateTime))
|
||||
.stream()
|
||||
.collect(Collectors.groupingBy(SysUserIdentityEntity::getUserId,
|
||||
Collectors.mapping(SysUserIdentityEntity::getIdentityType, Collectors.toList())));
|
||||
Map<Long, SysUserStudentEntity> studentByUserId = sysUserStudentService.listByIds(userIds).stream()
|
||||
.collect(Collectors.toMap(SysUserStudentEntity::getUserId, Function.identity(), (first, ignored) -> first));
|
||||
Map<Long, SysUserTeacherEntity> 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<String> 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);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -219,6 +219,18 @@
|
||||
<if test="query.staffingType != null">
|
||||
AND u.staffing_type = #{query.staffingType}
|
||||
</if>
|
||||
<!-- 多身份查询按任一身份命中处理,EXISTS 可避免身份表的一对多关系放大用户结果集。 -->
|
||||
<if test="query.identityTypes != null and query.identityTypes.size() != 0">
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_user_identity user_identity
|
||||
WHERE user_identity.user_id = u.user_id
|
||||
AND user_identity.identity_type IN
|
||||
<foreach collection="query.identityTypes" item="identityType" open="(" close=")" separator=",">
|
||||
#{identityType}
|
||||
</foreach>
|
||||
)
|
||||
</if>
|
||||
<if test="query.lockFlag != null and query.lockFlag != ''">
|
||||
AND u.lock_flag = #{query.lockFlag}
|
||||
</if>
|
||||
@@ -320,6 +332,18 @@
|
||||
<if test="query.deptId != null and query.deptId != ''">
|
||||
AND u.dept_id = #{query.deptId}
|
||||
</if>
|
||||
<!-- 导出沿用列表页的身份筛选语义,确保导出结果与当前查询一致。 -->
|
||||
<if test="query.identityTypes != null and query.identityTypes.size() != 0">
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_user_identity user_identity
|
||||
WHERE user_identity.user_id = u.user_id
|
||||
AND user_identity.identity_type IN
|
||||
<foreach collection="query.identityTypes" item="identityType" open="(" close=")" separator=",">
|
||||
#{identityType}
|
||||
</foreach>
|
||||
)
|
||||
</if>
|
||||
<if test="query.staffingType != null">
|
||||
AND u.staffing_type = #{query.staffingType}
|
||||
</if>
|
||||
|
||||
+34
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
+49
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
@@ -32,4 +32,9 @@ public class WalletBalanceTransactionDto {
|
||||
@NotNull(message = "是否暴力消费")
|
||||
private Boolean force = false;
|
||||
|
||||
/**
|
||||
* 财务统计标签字典值,多个值使用英文逗号分隔;未传时不为流水设置标签。
|
||||
*/
|
||||
private String tags;
|
||||
|
||||
}
|
||||
+5
@@ -39,6 +39,11 @@ public class WalletTransactionDto extends WalletTransactionEntity implements Bas
|
||||
|
||||
private List<String> bizTypes;
|
||||
|
||||
/**
|
||||
* 财务统计标签筛选值;传入多个字典值时匹配包含任一标签的流水。
|
||||
*/
|
||||
private List<String> tagList;
|
||||
|
||||
private LocalDate[] createTimeList;
|
||||
|
||||
/**
|
||||
|
||||
+30
@@ -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<Long> mchIds;
|
||||
|
||||
/**
|
||||
* 硬件订单类型编码;首期固定为预约订单编码 {@code 2}。
|
||||
*/
|
||||
@NotBlank(message = "订单类型不能为空")
|
||||
@Schema(description = "硬件订单类型编码")
|
||||
private String orderType;
|
||||
}
|
||||
+54
@@ -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;
|
||||
}
|
||||
+5
@@ -18,6 +18,11 @@ public class WalletRechargeImportDto extends WalletRechargeImportEntity implemen
|
||||
|
||||
private List<Long> userUniqueIds;
|
||||
|
||||
/**
|
||||
* 财务统计标签筛选值;传入多个字典值时匹配包含任一标签的导入记录。
|
||||
*/
|
||||
private List<String> tagList;
|
||||
|
||||
private Long userDeptId;
|
||||
|
||||
|
||||
|
||||
+26
@@ -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;
|
||||
}
|
||||
+15
@@ -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;
|
||||
|
||||
|
||||
+7
-1
@@ -78,6 +78,12 @@ public class WalletTransactionEntity extends Model<WalletTransactionEntity> {
|
||||
@Schema(description = "余额, 单位分")
|
||||
private Integer balance;
|
||||
|
||||
/**
|
||||
* 财务统计标签,保存 {@code wallet_transaction_tag} 字典值;多个值使用英文逗号分隔,历史流水可为空。
|
||||
*/
|
||||
@Schema(description = "财务统计标签,多个字典值使用英文逗号分隔")
|
||||
private String tags;
|
||||
|
||||
/**
|
||||
* 创建人
|
||||
*/
|
||||
@@ -119,4 +125,4 @@ public class WalletTransactionEntity extends Model<WalletTransactionEntity> {
|
||||
*/
|
||||
@Schema(description = "租户ID")
|
||||
private Long tenantId;
|
||||
}
|
||||
}
|
||||
+111
@@ -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<MchAutoRefundConfigEntity> {
|
||||
|
||||
/**
|
||||
* 配置主键,使用 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;
|
||||
}
|
||||
+7
-1
@@ -67,6 +67,12 @@ public class WalletRechargeImportEntity extends Model<WalletRechargeImportEntity
|
||||
@Schema(description = "交易金额, 单位分")
|
||||
private Integer price;
|
||||
|
||||
/**
|
||||
* 财务统计标签,保存 {@code wallet_transaction_tag} 字典值;多个值使用英文逗号分隔,未选择时为空。
|
||||
*/
|
||||
@Schema(description = "财务统计标签,多个字典值使用英文逗号分隔")
|
||||
private String tags;
|
||||
|
||||
/**
|
||||
* 创建人
|
||||
*/
|
||||
@@ -108,4 +114,4 @@ public class WalletRechargeImportEntity extends Model<WalletRechargeImportEntity
|
||||
*/
|
||||
@Schema(description = "租户ID")
|
||||
private Long tenantId;
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.bsd.pjcloud.wallet.api.feign;
|
||||
|
||||
import com.bsd.pjcloud.common.core.constant.ServiceNameConstants;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.feign.annotation.NoToken;
|
||||
import com.bsd.pjcloud.wallet.api.dto.mch.MchAutoRefundConfigBatchQueryDto;
|
||||
import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Wallet 商户自动退款配置的独立跨服务契约。
|
||||
*/
|
||||
@FeignClient(contextId = "remoteMchAutoRefundConfigService", value = ServiceNameConstants.WALLET_SERVICE)
|
||||
public interface RemoteMchAutoRefundConfigService {
|
||||
|
||||
/**
|
||||
* 按商户和订单类型精确查询当前租户配置,返回关闭配置以便 Hardware 区分暂停和未配置。
|
||||
*
|
||||
* @param mchId 商户 ID
|
||||
* @param orderType 硬件订单类型编码
|
||||
* @return 匹配配置;未配置时响应数据为空
|
||||
*/
|
||||
@NoToken
|
||||
@GetMapping("/mch-auto-refund-config/inner/exact")
|
||||
R<MchAutoRefundConfigVo> getExact(@RequestParam Long mchId, @RequestParam String orderType);
|
||||
|
||||
/**
|
||||
* 批量查询同一订单类型下的商户配置,结果不包含未配置商户。
|
||||
*
|
||||
* @param dto 商户列表和订单类型
|
||||
* @return 当前租户下匹配的配置列表
|
||||
*/
|
||||
@NoToken
|
||||
@PostMapping("/mch-auto-refund-config/inner/batch")
|
||||
R<List<MchAutoRefundConfigVo>> listBatch(@RequestBody MchAutoRefundConfigBatchQueryDto dto);
|
||||
}
|
||||
+16
-3
@@ -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<WalletRechargeEntity> getRechargeById(@PathVariable("rechargeId") Long rechargeId);
|
||||
|
||||
/**
|
||||
* 退款
|
||||
* 按售后单执行钱包退款,重复请求由 Wallet 校验资金目标和金额后返回原记录。
|
||||
*
|
||||
* @param dto
|
||||
* @return
|
||||
* @param dto Hardware 退款请求
|
||||
* @return 新建或已存在的钱包退款记录
|
||||
*/
|
||||
@NoToken
|
||||
@PostMapping("/wallet-recharge/refund")
|
||||
R<WalletRechargeVo> refund(@RequestBody WalletRechargeRefundDto dto);
|
||||
|
||||
/**
|
||||
* 按售后单 ID 查询已经持久化的退款记录,用于 Wallet 已到账但 Hardware 本地事务回滚后的恢复判断。
|
||||
*
|
||||
* @param afterSaleId Hardware 售后单 ID
|
||||
* @return 已存在退款记录;尚未退款时响应数据为空
|
||||
*/
|
||||
@NoToken
|
||||
@GetMapping("/wallet-recharge/remote/refund/{afterSaleId}")
|
||||
R<WalletRechargeEntity> getRefundByAfterSaleId(@PathVariable("afterSaleId") Long afterSaleId);
|
||||
|
||||
}
|
||||
+12
@@ -29,6 +29,18 @@ public interface RemoteWalletService {
|
||||
@GetMapping("/wallet-manage/get-wallet")
|
||||
R<WalletManageEntity> 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<List<WalletManageEntity>> getWalletsByUserIds(@RequestBody List<Long> userIds,
|
||||
@RequestParam WalletTypeEnum walletTypeCode);
|
||||
|
||||
@NoToken
|
||||
@GetMapping("/wallet-manage/get-wallet-by-wallet-id")
|
||||
R<WalletManageEntity> getWalletByWalletTypeId(@RequestParam Long userId, @RequestParam Long walletTypeId);
|
||||
|
||||
+21
@@ -31,6 +31,17 @@ public interface RemoteWalletTransactionService {
|
||||
@PostMapping("/wallet-transaction/reduce-balance-idempotent")
|
||||
R<WalletTransactionEntity> reduceWalletBalanceIdempotent(@RequestBody WalletBalanceTransactionDto dto);
|
||||
|
||||
/**
|
||||
* 在钱包服务单个事务内批量校验余额并按订单业务号幂等扣款。
|
||||
*
|
||||
* @param dtoList 每笔订单对应的钱包扣款明细
|
||||
* @return 与请求明细一一对应的钱包流水
|
||||
*/
|
||||
@NoToken
|
||||
@PostMapping("/wallet-transaction/reduce-balance-batch-idempotent")
|
||||
R<List<WalletTransactionEntity>> reduceWalletBalanceBatchIdempotent(
|
||||
@RequestBody List<WalletBalanceTransactionDto> dtoList);
|
||||
|
||||
/**
|
||||
* 仅在对应支付流水存在时执行幂等补偿退款。
|
||||
*/
|
||||
@@ -38,6 +49,16 @@ public interface RemoteWalletTransactionService {
|
||||
@PostMapping("/wallet-transaction/compensate-payment")
|
||||
R<Boolean> compensatePayment(@RequestBody WalletBalanceTransactionDto dto);
|
||||
|
||||
/**
|
||||
* 在钱包服务单个事务内批量补偿已存在的支付流水,重复调用不会重复入账。
|
||||
*
|
||||
* @param dtoList 待核对并补偿的订单支付明细
|
||||
* @return {@code true} 表示全部明细均已处于未扣款或已补偿状态
|
||||
*/
|
||||
@NoToken
|
||||
@PostMapping("/wallet-transaction/compensate-payment-batch")
|
||||
R<Boolean> compensatePaymentBatch(@RequestBody List<WalletBalanceTransactionDto> dtoList);
|
||||
|
||||
@NoToken
|
||||
@PostMapping("/wallet-transaction/sum-and-count")
|
||||
R<List<WalletTransactionSumAndCountVo>> sumAndCount(@RequestBody WalletTransactionSumAndCountDto dto);
|
||||
|
||||
+15
@@ -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 {
|
||||
}
|
||||
+5
@@ -33,6 +33,11 @@ public class GuardianWardRechargeItemVo {
|
||||
*/
|
||||
private String wardAvatar;
|
||||
|
||||
/**
|
||||
* 用户是否已经开通 EAT 饭卡。该字段用于区分未开户与已开户但余额为零,旧客户端可安全忽略。
|
||||
*/
|
||||
private Boolean walletOpened;
|
||||
|
||||
/**
|
||||
* 饭卡类型名称。
|
||||
*/
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+36
@@ -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<List<SysDictItem>> result = remoteDictService.getDictByType(dictType);
|
||||
if (result == null || !result.isOk() || result.getData() == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return result.getData().stream().map(SysDictItem::getLabel).toList();
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
+14
@@ -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<List<WalletManageEntity>> getWalletsByUserIds(@RequestBody List<Long> userIds,
|
||||
@RequestParam WalletTypeEnum walletTypeCode) {
|
||||
return R.ok(walletManageService.getWalletsByUserIds(userIds, walletTypeCode));
|
||||
}
|
||||
|
||||
@Inner
|
||||
@GetMapping("/get-wallet-by-id")
|
||||
public R<WalletManageVo> getWalletById(@RequestParam Long walletId) {
|
||||
|
||||
+26
-1
@@ -56,7 +56,7 @@ public class WalletTransactionController {
|
||||
@Inner
|
||||
@PostMapping("/add-balance")
|
||||
public R<WalletTransactionEntity> 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<List<WalletTransactionEntity>> reduceWalletBalanceBatchIdempotent(
|
||||
@Valid @RequestBody List<WalletBalanceTransactionDto> 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<Boolean> compensatePaymentBatch(@Valid @RequestBody List<WalletBalanceTransactionDto> dtoList) {
|
||||
return R.ok(walletTransactionService.compensatePaymentBatch(dtoList));
|
||||
}
|
||||
|
||||
@Inner
|
||||
@PostMapping("/sum-and-count")
|
||||
public R<List<WalletTransactionSumAndCountVo>> sumAndCount(@RequestBody WalletTransactionSumAndCountDto dto) {
|
||||
|
||||
+96
@@ -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<MchAutoRefundConfigVo> 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<MchAutoRefundConfigVo> 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<MchAutoRefundConfigVo> 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<List<MchAutoRefundConfigVo>> listBatch(@Valid @RequestBody MchAutoRefundConfigBatchQueryDto dto) {
|
||||
return R.ok(configService.listBatch(dto));
|
||||
}
|
||||
}
|
||||
+16
@@ -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<WalletRechargeVo> 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<WalletRechargeEntity> getRefundByAfterSaleId(@PathVariable Long afterSaleId) {
|
||||
return R.ok(walletRechargeService.getByRefundId(afterSaleId));
|
||||
}
|
||||
/* ************************************* end 远程调用 end ************************************* */
|
||||
|
||||
/* ************************************* start app start ************************************* */
|
||||
|
||||
+4
-1
@@ -74,7 +74,10 @@ public interface WalletManageMapper extends PjcloudBaseMapper<WalletManageEntity
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁定钱包行,使同一钱包的业务幂等检查和余额变更处于同一事务串行执行。
|
||||
* 按主键锁定未逻辑删除的钱包行,使同一钱包的业务幂等检查和余额变更在当前事务内串行执行。
|
||||
*
|
||||
* @param id 资金目标钱包主键
|
||||
* @return 已锁定钱包;钱包不存在或已逻辑删除时返回 {@code null}
|
||||
*/
|
||||
default WalletManageEntity selectByIdForUpdate(Long id) {
|
||||
return selectOne(new LambdaQueryWrapper<WalletManageEntity>()
|
||||
|
||||
+22
-2
@@ -61,6 +61,20 @@ public interface WalletTransactionMapper extends PjcloudBaseMapper<WalletTransac
|
||||
} else if (Objects.equals(dto.getType(), WalletTransactionDto.TYPE_EXPENSE)) {
|
||||
wrapper.lt(WalletTransactionEntity::getPrice, 0);
|
||||
}
|
||||
if (ObjectUtil.isNotEmpty(dto.getTagList())) {
|
||||
wrapper.and(nested -> {
|
||||
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<WalletTransac
|
||||
|
||||
List<WalletTransactionSumAndCountVo> sumAndCount(@Param("dto") WalletTransactionSumAndCountDto dto);
|
||||
|
||||
List<WalletExportCzTotalPerson> exportCzTotalPerson();
|
||||
}
|
||||
/**
|
||||
* 查询固定金额范围内的个人充值统计明细,并按财务标签筛选。
|
||||
*
|
||||
* @param dto 充值统计筛选条件;标签多选时匹配包含任一标签的流水
|
||||
* @return 个人充值统计导出明细
|
||||
*/
|
||||
List<WalletExportCzTotalPerson> exportCzTotalPerson(@Param("dto") WalletTransactionDto dto);
|
||||
}
|
||||
+39
@@ -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<MchAutoRefundConfigEntity> {
|
||||
|
||||
/**
|
||||
* 按主键锁定配置行,保证开关修改和自动退款最终校验严格串行。
|
||||
*
|
||||
* @param id 配置主键
|
||||
* @return 已锁定配置;不存在或已逻辑删除时返回 {@code null}
|
||||
*/
|
||||
default MchAutoRefundConfigEntity selectByIdForUpdate(Long id) {
|
||||
return selectOne(new LambdaQueryWrapper<MchAutoRefundConfigEntity>()
|
||||
.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<MchAutoRefundConfigEntity>()
|
||||
.eq(MchAutoRefundConfigEntity::getMchId, mchId)
|
||||
.eq(MchAutoRefundConfigEntity::getOrderType, orderType)
|
||||
.last("FOR UPDATE"));
|
||||
}
|
||||
}
|
||||
+65
@@ -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<MchAutoRefundConfigEntity> {
|
||||
|
||||
/**
|
||||
* 在当前管理用户数据范围内查询指定商户配置。
|
||||
*
|
||||
* @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<MchAutoRefundConfigVo> listBatch(MchAutoRefundConfigBatchQueryDto dto);
|
||||
|
||||
/**
|
||||
* 在当前事务中仅锁定自动退款配置,不提前判断开关;钱包需先用该锁串行配置修改和幂等记录检查。
|
||||
*
|
||||
* @param configId 订单快照和任务引用的配置主键
|
||||
* @return 已锁定配置
|
||||
*/
|
||||
MchAutoRefundConfigEntity lockForUpdate(Long configId);
|
||||
|
||||
/**
|
||||
* 在当前事务中锁定并校验自动退款配置仍然开启且规则有效。
|
||||
*
|
||||
* @param configId 订单快照和任务引用的配置主键
|
||||
* @return 已锁定且可用的配置
|
||||
*/
|
||||
MchAutoRefundConfigEntity requireEnabledForUpdate(Long configId);
|
||||
}
|
||||
+10
-1
@@ -39,6 +39,15 @@ public interface WalletManageService extends IService<WalletManageEntity> {
|
||||
*/
|
||||
WalletManageEntity getWallet(@NotNull Long userId, @NotNull WalletTypeEnum walletTypeCode);
|
||||
|
||||
/**
|
||||
* 按钱包业务类型批量查询指定用户已经存在的钱包,不为缺失用户自动开户。
|
||||
*
|
||||
* @param userIds 目标用户 ID 列表,空列表返回空结果
|
||||
* @param walletTypeCode 钱包业务类型
|
||||
* @return 已存在的钱包列表,调用方需自行判断缺失用户
|
||||
*/
|
||||
List<WalletManageEntity> getWalletsByUserIds(List<Long> userIds, @NotNull WalletTypeEnum walletTypeCode);
|
||||
|
||||
/**
|
||||
* 获取钱包(没有缓存)
|
||||
*/
|
||||
@@ -135,4 +144,4 @@ public interface WalletManageService extends IService<WalletManageEntity> {
|
||||
* 编辑外卖配置
|
||||
*/
|
||||
MchTakeOutSettingEntity editTakeOut(MchTakeOutSettingEntity dto);
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -73,18 +73,18 @@ public interface WalletRechargeService extends IService<WalletRechargeEntity> {
|
||||
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);
|
||||
}
|
||||
+39
@@ -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<WalletTransactionEnti
|
||||
*/
|
||||
WalletTransactionEntity reduceWalletBalanceIdempotent(Long walletId, Integer price, String bizId, WalletBizEnum bizType, Boolean force);
|
||||
|
||||
/**
|
||||
* 在同一本地事务内锁定全部钱包、校验整批余额并按业务号幂等扣款。
|
||||
*
|
||||
* @param dtoList 每笔订单对应的扣款明细;仅支持非强制的 {@link WalletBizEnum#PAYMENT} 扣款
|
||||
* @return 与请求顺序一致的钱包支付流水
|
||||
*/
|
||||
List<WalletTransactionEntity> reduceWalletBalanceBatchIdempotent(List<WalletBalanceTransactionDto> dtoList);
|
||||
|
||||
/**
|
||||
* 对指定支付业务执行幂等补偿;支付流水不存在时不增加余额。
|
||||
*/
|
||||
Boolean compensatePayment(Long walletId, Integer price, String bizId);
|
||||
|
||||
/**
|
||||
* 在同一本地事务内批量补偿已存在的支付流水,未扣款和已补偿明细均按成功处理。
|
||||
*
|
||||
* @param dtoList 待核对并补偿的订单支付明细
|
||||
* @return {@code true} 表示整批均已处于未扣款或已补偿状态
|
||||
*/
|
||||
Boolean compensatePaymentBatch(List<WalletBalanceTransactionDto> dtoList);
|
||||
|
||||
|
||||
/**
|
||||
* 增加钱包余额
|
||||
@@ -69,6 +86,18 @@ public interface WalletTransactionService extends IService<WalletTransactionEnti
|
||||
*/
|
||||
WalletTransactionEntity addWalletBalance(Long walletId, Integer price, String bizId, WalletBizEnum bizType);
|
||||
|
||||
/**
|
||||
* 增加钱包余额并记录财务统计标签。
|
||||
*
|
||||
* @param walletId 钱包 id
|
||||
* @param price 增加金额,单位分
|
||||
* @param bizId 业务关联 id
|
||||
* @param bizType 业务关联分类
|
||||
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
|
||||
* @return 已保存的钱包流水
|
||||
*/
|
||||
WalletTransactionEntity addWalletBalance(Long walletId, Integer price, String bizId, WalletBizEnum bizType, String tags);
|
||||
|
||||
/**
|
||||
* 更新会员用户余额
|
||||
*
|
||||
@@ -85,6 +114,16 @@ public interface WalletTransactionService extends IService<WalletTransactionEnti
|
||||
*/
|
||||
Boolean resetWalletBalance(Long walletId, String bizId);
|
||||
|
||||
/**
|
||||
* 重置钱包余额并记录财务统计标签。
|
||||
*
|
||||
* @param walletId 钱包 id
|
||||
* @param bizId 业务关联 id
|
||||
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
|
||||
* @return 重置成功返回 {@code true}
|
||||
*/
|
||||
Boolean resetWalletBalance(Long walletId, String bizId, String tags);
|
||||
|
||||
void exportByTemplate(HttpServletResponse response, WalletTransactionDto dto, String fileName, List<WalletTransactionVo> list);
|
||||
|
||||
/**
|
||||
|
||||
+26
-1
@@ -169,6 +169,31 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次查询多个用户指定业务类型的钱包,避免批量订餐逐人发起远程调用。
|
||||
*
|
||||
* @param userIds 目标用户 ID 列表,空值和重复值在查询前清理
|
||||
* @param walletTypeCode 钱包业务类型
|
||||
* @return 已存在的钱包列表;不会为缺失用户自动创建钱包
|
||||
*/
|
||||
@Override
|
||||
public List<WalletManageEntity> getWalletsByUserIds(List<Long> userIds, WalletTypeEnum walletTypeCode) {
|
||||
if (ObjectUtil.isEmpty(userIds)) {
|
||||
return List.of();
|
||||
}
|
||||
WalletTypeEntity walletType = walletTypeService.getByUniqueCode(walletTypeCode);
|
||||
if (ObjectUtil.isEmpty(walletType)) {
|
||||
return List.of();
|
||||
}
|
||||
List<Long> distinctUserIds = userIds.stream().filter(Objects::nonNull).distinct().toList();
|
||||
if (distinctUserIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return baseMapper.selectList(new LambdaQueryWrapper<WalletManageEntity>()
|
||||
.in(WalletManageEntity::getUserId, distinctUserIds)
|
||||
.eq(WalletManageEntity::getWalletTypeId, walletType.getId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public WalletManageEntity getWallet(String cardNumber) {
|
||||
return baseMapper.selectOne(new LambdaQueryWrapper<WalletManageEntity>()
|
||||
@@ -662,4 +687,4 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
|
||||
private WalletManageServiceImpl getSelf() {
|
||||
return SpringUtil.getBean(getClass());
|
||||
}
|
||||
}
|
||||
}
|
||||
+387
-2
@@ -28,6 +28,7 @@ import com.bsd.pjcloud.common.data.datascope.PjcloudDefaultDataScopeHandle;
|
||||
import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum;
|
||||
import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
|
||||
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
|
||||
import com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto;
|
||||
import com.bsd.pjcloud.wallet.api.dto.WalletManageDto;
|
||||
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto;
|
||||
import com.bsd.pjcloud.wallet.api.dto.WalletTypeDto;
|
||||
@@ -50,8 +51,12 @@ import com.bsd.pjcloud.wallet.service.WalletTransactionService;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.redisson.Redisson;
|
||||
import org.redisson.api.RLock;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -59,8 +64,14 @@ import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.ListIterator;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -77,6 +88,32 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
|
||||
static private final String TAG = "会员钱包流水";
|
||||
|
||||
/**
|
||||
* 批量订单支付与补偿共用的分布式锁前缀,锁粒度为“钱包 ID + 订单业务号”。
|
||||
*/
|
||||
private static final String PAYMENT_OPERATION_LOCK_PREFIX = "wallet:payment:operation:";
|
||||
|
||||
/**
|
||||
* 已取消订单支付的终态标记前缀,用于阻止 Feign 超时后才到达钱包服务的迟到扣款。
|
||||
*/
|
||||
private static final String PAYMENT_CANCELLED_MARKER_PREFIX = "wallet:payment:cancelled:";
|
||||
|
||||
/**
|
||||
* 单个订单交易锁最长等待秒数;超时后整批失败,避免请求线程长期占用。
|
||||
*/
|
||||
private static final long PAYMENT_OPERATION_LOCK_WAIT_SECONDS = 10L;
|
||||
|
||||
/**
|
||||
* 已取消支付标记保留天数。订单 ID 不会在业务上复用,七天覆盖网关、Feign 与人工重试窗口,
|
||||
* 同时避免创建违反项目约定的永久 Redis 键。
|
||||
*/
|
||||
private static final long PAYMENT_CANCELLED_MARKER_TTL_DAYS = 7L;
|
||||
|
||||
/**
|
||||
* 已取消支付标记值,仅表达订单终态,不替代数据库 PAYMENT/REFUND 流水。
|
||||
*/
|
||||
private static final String PAYMENT_CANCELLED_MARKER_VALUE = "CANCELLED";
|
||||
|
||||
private final RemoteDataScopeService dataScopeService;
|
||||
|
||||
private final WalletManageService walletManageService;
|
||||
@@ -91,6 +128,11 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
private final RemoteUserService userService;
|
||||
private final RemoteDeptService deptService;
|
||||
|
||||
/**
|
||||
* 项目统一 Redisson 客户端,用于协调并发扣款、补偿和记录迟到扣款禁止标记。
|
||||
*/
|
||||
private final Redisson redisson;
|
||||
|
||||
|
||||
@Override
|
||||
public boolean save(WalletTransactionEntity entity) {
|
||||
@@ -154,6 +196,76 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
return transaction;
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁定整批涉及的钱包后再统一校验和扣款,避免多人订餐在并发余额变化时只扣到部分人员。
|
||||
* 已存在且金额一致的支付流水按幂等成功返回;业务号已经补偿退款时拒绝重新扣款,防止订单被误判为已支付。
|
||||
*
|
||||
* @param dtoList 每笔订单对应的支付明细,业务类型固定为支付且不允许透支
|
||||
* @return 与请求顺序一致的钱包支付流水
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public List<WalletTransactionEntity> reduceWalletBalanceBatchIdempotent(List<WalletBalanceTransactionDto> dtoList) {
|
||||
List<WalletBalanceTransactionDto> payments = validatePaymentBatch(dtoList);
|
||||
acquirePaymentOperationLocks(payments, new ArrayList<>());
|
||||
if (payments.stream().anyMatch(this::isPaymentCancelled)) {
|
||||
throw new RuntimeException("订单支付已经取消,不能继续扣款");
|
||||
}
|
||||
Map<Long, WalletManageEntity> lockedWalletMap = lockBatchWallets(payments);
|
||||
Map<Long, Integer> pendingPriceMap = new LinkedHashMap<>();
|
||||
Map<String, WalletTransactionEntity> 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<Long, Integer> runningBalanceMap = new HashMap<>();
|
||||
lockedWalletMap.forEach((walletId, wallet) -> runningBalanceMap.put(walletId, wallet.getBalance()));
|
||||
List<WalletTransactionEntity> 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<WalletTransactionM
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对整批订单支付流水执行同事务补偿。钱包按 ID 固定顺序加锁,既避免死锁,也保证任何流水校验失败时
|
||||
* 不会留下部分人员已退款、部分人员未退款的批次状态。
|
||||
*
|
||||
* @param dtoList 待补偿的订单支付明细
|
||||
* @return {@code true} 表示全部支付均未发生或已经完成对应补偿
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Boolean compensatePaymentBatch(List<WalletBalanceTransactionDto> dtoList) {
|
||||
List<WalletBalanceTransactionDto> payments = validatePaymentBatch(dtoList);
|
||||
List<String> rollbackMarkerKeys = new ArrayList<>();
|
||||
acquirePaymentOperationLocks(payments, rollbackMarkerKeys);
|
||||
Map<Long, WalletManageEntity> lockedWalletMap = lockBatchWallets(payments);
|
||||
Map<Long, Integer> refundPriceMap = new LinkedHashMap<>();
|
||||
Set<String> 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<Long, Integer> 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<WalletBalanceTransactionDto> validatePaymentBatch(List<WalletBalanceTransactionDto> dtoList) {
|
||||
if (ObjectUtil.isEmpty(dtoList) || dtoList.stream().anyMatch(Objects::isNull)) {
|
||||
throw new RuntimeException("钱包批量交易明细不能为空");
|
||||
}
|
||||
Set<String> 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<Long, WalletManageEntity> lockBatchWallets(List<WalletBalanceTransactionDto> payments) {
|
||||
Map<Long, WalletManageEntity> 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<WalletBalanceTransactionDto> payments, List<String> rollbackMarkerKeys) {
|
||||
List<RLock> 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<RLock> locks, List<String> rollbackMarkerKeys) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
throw new RuntimeException("钱包批量交易事务状态异常,请稍后重试");
|
||||
}
|
||||
List<RLock> 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<String> 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<RLock> locks) {
|
||||
ListIterator<RLock> 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<String> 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<WalletTransactionEntity>()
|
||||
.eq(WalletTransactionEntity::getWalletId, walletId)
|
||||
@@ -212,6 +565,22 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
// @CacheEvict(value = CacheConstants.WALLET_DETAIL, key = "#walletId")
|
||||
public WalletTransactionEntity addWalletBalance(Long walletId, Integer price, String bizId, WalletBizEnum bizType) {
|
||||
return getSelf().addWalletBalance(walletId, price, bizId, bizType, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加钱包余额并将来源业务的财务标签同步到最终流水,供后续列表筛选和金额统计。
|
||||
*
|
||||
* @param walletId 钱包 id
|
||||
* @param price 增加金额,单位分
|
||||
* @param bizId 业务关联 id
|
||||
* @param bizType 业务关联分类
|
||||
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
|
||||
* @return 已保存的钱包流水
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public WalletTransactionEntity addWalletBalance(Long walletId, Integer price, String bizId, WalletBizEnum bizType, String tags) {
|
||||
WalletManageEntity wallet = walletManageService.getById(walletId);
|
||||
if (ObjectUtil.isEmpty(wallet)) {
|
||||
log.error("|{}|,用户钱包id({})不存在.", TAG, walletId);
|
||||
@@ -241,6 +610,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
insetObj.setBizId(String.valueOf(bizId));
|
||||
insetObj.setBizType(bizType.getCode());
|
||||
insetObj.setTitle(bizType.getDesc());
|
||||
insetObj.setTags(tags);
|
||||
save(insetObj);
|
||||
return insetObj;
|
||||
}
|
||||
@@ -257,7 +627,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
throw new RuntimeException("用户钱包不存在");
|
||||
}
|
||||
// 更新钱包余额
|
||||
WalletTransactionEntity result = getSelf().addWalletBalance(wallet.getId(), dto.getBalance(), String.valueOf(dto.getUserId()), WalletBizEnum.UPDATE);
|
||||
WalletTransactionEntity result = getSelf().addWalletBalance(wallet.getId(), dto.getBalance(), String.valueOf(dto.getUserId()), WalletBizEnum.UPDATE, dto.getTags());
|
||||
// 查询钱包类型
|
||||
WalletTypeEntity walletType = walletTypeMapper.selectById(wallet.getWalletTypeId());
|
||||
if (ObjectUtil.isNotEmpty(walletType) && ObjectUtil.equals(walletType.getUniqueCode(), WalletTypeEnum.EAT.getCode())) {
|
||||
@@ -276,6 +646,20 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Boolean resetWalletBalance(Long walletId, String bizId) {
|
||||
return getSelf().resetWalletBalance(walletId, bizId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置钱包余额并将来源导入记录的财务标签同步到重置流水。
|
||||
*
|
||||
* @param walletId 钱包 id
|
||||
* @param bizId 业务关联 id
|
||||
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
|
||||
* @return 重置成功返回 {@code true}
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Boolean resetWalletBalance(Long walletId, String bizId, String tags) {
|
||||
WalletManageEntity wallet = walletManageService.getById(walletId);
|
||||
if (ObjectUtil.isEmpty(wallet)) {
|
||||
log.error("|{}|重置余额,用户钱包id({})不存在.", TAG, walletId);
|
||||
@@ -298,6 +682,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
insetObj.setBizId(String.valueOf(bizId));
|
||||
insetObj.setBizType(WalletBizEnum.RESET.getCode());
|
||||
insetObj.setTitle(WalletBizEnum.RESET.getDesc());
|
||||
insetObj.setTags(tags);
|
||||
save(insetObj);
|
||||
return true;
|
||||
}
|
||||
@@ -535,7 +920,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
String fileName = "file/个人充值统计800、1600、2400.xlsx";
|
||||
try (ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).withTemplate(ResourceUtil.getStream(fileName)).build()) {
|
||||
// 查询导出数据
|
||||
List<WalletExportCzTotalPerson> list = baseMapper.exportCzTotalPerson();
|
||||
List<WalletExportCzTotalPerson> list = baseMapper.exportCzTotalPerson(dto);
|
||||
|
||||
WriteSheet writeSheet = EasyExcel.writerSheet().build();
|
||||
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
|
||||
|
||||
+254
@@ -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<MchAutoRefundConfigMapper, MchAutoRefundConfigEntity>
|
||||
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<MchAutoRefundConfigEntity>()
|
||||
.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<MchAutoRefundConfigVo> listBatch(MchAutoRefundConfigBatchQueryDto dto) {
|
||||
validateOrderType(dto.getOrderType());
|
||||
List<Long> mchIds = dto.getMchIds().stream().filter(Objects::nonNull).distinct().toList();
|
||||
if (mchIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<MchAutoRefundConfigEntity> entities = baseMapper.selectList(new LambdaQueryWrapper<MchAutoRefundConfigEntity>()
|
||||
.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<MchManageVo> 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;
|
||||
}
|
||||
}
|
||||
+52
-8
@@ -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<WalletRechargeAp
|
||||
|
||||
static private final String TAG = "充值/扣款申请";
|
||||
|
||||
/**
|
||||
* 充值流水财务标签使用的 UPMS 字典类型,Excel 展示标签而数据库保存对应字典值。
|
||||
*/
|
||||
private static final String TRANSACTION_TAG_DICT_TYPE = "wallet_transaction_tag";
|
||||
|
||||
private final WalletRechargeImportServiceImpl rechargeImportService;
|
||||
private final WalletManageService walletManageService;
|
||||
private final WalletTransactionService walletTransactionService;
|
||||
@@ -103,6 +110,7 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl<WalletRechargeAp
|
||||
|
||||
private final RemoteUserService userService;
|
||||
private final RemoteDeptService deptService;
|
||||
private final RemoteDictService remoteDictService;
|
||||
private final RemoteFlowEngineService flowEngineService;
|
||||
|
||||
private final FlowUtil flowUtil;
|
||||
@@ -328,7 +336,7 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl<WalletRechargeAp
|
||||
if (i <= 0) {
|
||||
throw new RuntimeException("导入失败");
|
||||
}
|
||||
List<WalletRechargeImportEntity> batchSaveList = list.stream().map(v -> WalletRechargeImportEntity.builder().price(v.getPriceNumber()).userId(v.getUserId()).userDeptId(v.getUserDeptId()).build()).toList();
|
||||
List<WalletRechargeImportEntity> 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<WalletRechargeAp
|
||||
if (i <= 0) {
|
||||
throw new RuntimeException("导入失败");
|
||||
}
|
||||
List<WalletRechargeImportEntity> batchSaveList = list.stream().map(v -> WalletRechargeImportEntity.builder().price(0).userId(v.getUserId()).userDeptId(v.getUserDeptId()).build()).toList();
|
||||
List<WalletRechargeImportEntity> 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<WalletRechargeAp
|
||||
WalletRechargeInvoiceEntity invoice = walletRechargeInvoiceService.createBaseInvoice(loginUser, totalPrice);
|
||||
if (ObjectUtil.isEmpty(invoice)) throw new RuntimeException("导入失败");
|
||||
// 导入数据
|
||||
List<WalletRechargeImportEntity> batchSaveList = list.stream().map(v -> WalletRechargeImportEntity.builder().price(v.getPriceNumber()).userId(v.getUserId()).userDeptId(v.getUserDeptId()).invoiceId(invoice.getId()).build()).toList();
|
||||
List<WalletRechargeImportEntity> 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<WalletRechargeAp
|
||||
throw new RuntimeException("读取excel数据错误");
|
||||
}
|
||||
List<ErrorMessage> errorMessageList = (List<ErrorMessage>) bindingResult.getTarget();
|
||||
resolveTransactionTagValues(list, errorMessageList);
|
||||
List<String> usernameList = list.stream().map(WalletRechargeExcelDto::getUsername).toList();
|
||||
List<String> userPoneList = list.stream().map(WalletRechargeExcelDto::getUserMobile).toList();
|
||||
for (WalletRechargeExcelDto v : list) {
|
||||
@@ -442,6 +451,41 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl<WalletRechargeAp
|
||||
return ImmutablePair.of(null, list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Excel 中展示的财务标签转换为稳定的字典值,供导入记录和最终钱包流水持久化及统计查询。
|
||||
*
|
||||
* @param list 已通过公共 Excel 字典标签校验的数据行
|
||||
* @param errorMessageList 当前导入任务的行级错误集合,字典服务异常或标签映射缺失时追加错误
|
||||
*/
|
||||
private void resolveTransactionTagValues(List<WalletRechargeExcelDto> list, List<ErrorMessage> errorMessageList) {
|
||||
List<WalletRechargeExcelDto> taggedRows = list.stream().filter(item -> ObjectUtil.isNotEmpty(item.getTag())).toList();
|
||||
if (taggedRows.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
R<List<SysDictItem>> 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<String, String> 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<String> 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<WalletRechargeAp
|
||||
}
|
||||
// 判断是`退还各自食堂`还是`批量更新余额`
|
||||
WalletBizEnum walletBiz = ObjectUtil.equal(WalletBizRechargeEnum.RETURN_CANTEEN.getCode(), detail.getBizType()) ? WalletBizEnum.RETURN_CANTEEN : WalletBizEnum.BATCH;
|
||||
walletTransactionService.addWalletBalance(wallet.getId(), v.getPrice(), String.valueOf(v.getId()), walletBiz);
|
||||
walletTransactionService.addWalletBalance(wallet.getId(), v.getPrice(), String.valueOf(v.getId()), walletBiz, v.getTags());
|
||||
}
|
||||
// 同步到正元钱包状态
|
||||
List<Long> userIds = importList.stream().map(WalletRechargeImportEntity::getUserId).distinct().toList();
|
||||
@@ -548,7 +592,7 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl<WalletRechargeAp
|
||||
if (ObjectUtil.isEmpty(wallet)) {
|
||||
continue;
|
||||
}
|
||||
walletTransactionService.resetWalletBalance(wallet.getId(), String.valueOf(v.getId()));
|
||||
walletTransactionService.resetWalletBalance(wallet.getId(), String.valueOf(v.getId()), v.getTags());
|
||||
}
|
||||
log.info("|{}|重置余额审批回调,dto:|{}", TAG, JSON.toJSONString(detail));
|
||||
} catch (Exception e) {
|
||||
@@ -766,9 +810,9 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl<WalletRechargeAp
|
||||
if (ObjectUtil.isEmpty(cardWalletVo)) continue;
|
||||
// 先判断消费钱包是不是够用
|
||||
int discountPrice = payWalletVo.getBalance() - i.getPrice();
|
||||
walletTransactionService.addWalletBalance(payWalletVo.getId(), payWalletVo.getBalance() * -1, String.valueOf(i.getId()), WalletBizEnum.BATCH);
|
||||
walletTransactionService.addWalletBalance(payWalletVo.getId(), payWalletVo.getBalance() * -1, String.valueOf(i.getId()), WalletBizEnum.BATCH, i.getTags());
|
||||
if (discountPrice < 0) {
|
||||
walletTransactionService.addWalletBalance(cardWalletVo.getId(), discountPrice, String.valueOf(i.getId()), WalletBizEnum.BATCH);
|
||||
walletTransactionService.addWalletBalance(cardWalletVo.getId(), discountPrice, String.valueOf(i.getId()), WalletBizEnum.BATCH, i.getTags());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -776,4 +820,4 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl<WalletRechargeAp
|
||||
private WalletRechargeApplyServiceImpl getSelf() {
|
||||
return SpringUtil.getBean(getClass());
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -124,7 +124,7 @@ public class WalletRechargeImportServiceImpl extends ServiceImpl<WalletRechargeI
|
||||
if (ObjectUtil.isNotEmpty(walletTransaction)) throw new RuntimeException("钱包已经充值过");
|
||||
WalletManageEntity wallet = walletManageService.getOrCreateWallet(rechargeImport.getUserId(), walletTypeEnum);
|
||||
if (ObjectUtil.isEmpty(wallet)) throw new RuntimeException("钱包错误");
|
||||
walletTransactionService.addWalletBalance(wallet.getId(), rechargeImport.getPrice(), String.valueOf(rechargeImport.getId()), WalletBizEnum.BATCH);
|
||||
walletTransactionService.addWalletBalance(wallet.getId(), rechargeImport.getPrice(), String.valueOf(rechargeImport.getId()), WalletBizEnum.BATCH, rechargeImport.getTags());
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -149,4 +149,4 @@ public class WalletRechargeImportServiceImpl extends ServiceImpl<WalletRechargeI
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
-4
@@ -36,6 +36,8 @@ import com.bsd.pjcloud.wallet.api.vo.recharge.app.GuardianWardRechargeItemVo;
|
||||
import com.bsd.pjcloud.wallet.api.vo.recharge.app.GuardianWardRechargePackageVo;
|
||||
import com.bsd.pjcloud.wallet.api.vo.recharge.app.GuardianWardRechargeResultVo;
|
||||
import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargeMapper;
|
||||
import com.bsd.pjcloud.wallet.mapper.WalletManageMapper;
|
||||
import com.bsd.pjcloud.wallet.service.MchAutoRefundConfigService;
|
||||
import com.bsd.pjcloud.wallet.service.MchManageService;
|
||||
import com.bsd.pjcloud.wallet.service.WalletManageService;
|
||||
import com.bsd.pjcloud.wallet.service.WalletRechargePackageService;
|
||||
@@ -47,6 +49,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@@ -76,6 +79,12 @@ public class WalletRechargeServiceImpl extends ServiceImpl<WalletRechargeMapper,
|
||||
private final WalletManageService walletManageService;
|
||||
private final WalletTransactionService walletTransactionService;
|
||||
|
||||
/** 钱包 Mapper,用于退款事务在幂等检查前锁定资金目标。 */
|
||||
private final WalletManageMapper walletManageMapper;
|
||||
|
||||
/** 商户自动退款配置服务,用于串行配置修改、幂等检查和新退款最终开关校验。 */
|
||||
private final MchAutoRefundConfigService mchAutoRefundConfigService;
|
||||
|
||||
/** 钱包类型服务,用于返回饭卡类型展示名称。 */
|
||||
private final WalletTypeService walletTypeService;
|
||||
|
||||
@@ -258,16 +267,30 @@ public class WalletRechargeServiceImpl extends ServiceImpl<WalletRechargeMapper,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按售后单 ID 幂等退款。自动请求先锁配置、再锁钱包;已有记录即使开关关闭也只返回原结果,
|
||||
* 没有记录的新退款必须在持有两把行锁时再次确认开关开启。
|
||||
*
|
||||
* @param dto Hardware 退款请求
|
||||
* @return 新建或已存在的钱包退款记录
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public WalletRechargeVo refund(WalletRechargeRefundDto dto) {
|
||||
// 查询钱包
|
||||
WalletManageEntity wallet = walletManageService.getWallet(dto.getWalletId());
|
||||
if (dto.getAutoRefundConfigId() != null) {
|
||||
// 配置锁必须早于钱包锁;先不判断开关,确保已到账的幂等请求在关闭后仍能恢复 Hardware 状态。
|
||||
mchAutoRefundConfigService.lockForUpdate(dto.getAutoRefundConfigId());
|
||||
}
|
||||
WalletManageEntity wallet = walletManageMapper.selectByIdForUpdate(dto.getWalletId());
|
||||
if (ObjectUtil.isEmpty(wallet)) throw new RuntimeException("用户钱包不存在");
|
||||
// 每次部分退款以售后单 ID 幂等;重复调用返回原退款单,不再次增加钱包余额。
|
||||
WalletRechargeEntity refund = getByRefundId(dto.getAfterSaleId());
|
||||
if (ObjectUtil.isNotEmpty(refund)) {
|
||||
return BeanUtils.toBean(refund, WalletRechargeVo.class);
|
||||
return validateExistingRefund(dto, refund);
|
||||
}
|
||||
if (dto.getAutoRefundConfigId() != null) {
|
||||
// 配置行仍由当前事务持有,二次读取不会改变锁顺序;关闭接口返回后不会有新资金请求通过这里。
|
||||
mchAutoRefundConfigService.requireEnabledForUpdate(dto.getAutoRefundConfigId());
|
||||
}
|
||||
WalletRechargeEntity recharge = WalletRechargeEntity.builder()
|
||||
.walletId(wallet.getId())
|
||||
@@ -280,12 +303,40 @@ public class WalletRechargeServiceImpl extends ServiceImpl<WalletRechargeMapper,
|
||||
.refundStatus(HardRefundStatusEnum.SUCCESS.getStatus())
|
||||
.refundTime(LocalDateTime.now())
|
||||
.build();
|
||||
baseMapper.insert(recharge);
|
||||
try {
|
||||
if (baseMapper.insert(recharge) != 1) {
|
||||
throw new RuntimeException("创建钱包退款幂等记录失败");
|
||||
}
|
||||
} catch (DuplicateKeyException exception) {
|
||||
// 不同钱包并发提交相同售后单时无法依靠钱包行锁串行,唯一键冲突后重查并严格核对原记录。
|
||||
WalletRechargeEntity existing = getByRefundId(dto.getAfterSaleId());
|
||||
if (existing == null) {
|
||||
throw exception;
|
||||
}
|
||||
return validateExistingRefund(dto, existing);
|
||||
}
|
||||
// 增加金额
|
||||
walletTransactionService.addWalletBalance(wallet.getId(), dto.getPrice(), String.valueOf(dto.getAfterSaleId()), WalletBizEnum.REFUND);
|
||||
return BeanUtils.toBean(recharge, WalletRechargeVo.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验幂等退款记录的资金目标和金额,防止错误复用售后单 ID 静默返回另一笔退款。
|
||||
*
|
||||
* @param dto 当前退款请求
|
||||
* @param existing 已持久化的原退款记录
|
||||
* @return 校验通过后的原退款响应
|
||||
*/
|
||||
private WalletRechargeVo validateExistingRefund(WalletRechargeRefundDto dto, WalletRechargeEntity existing) {
|
||||
if (!Objects.equals(existing.getWalletId(), dto.getWalletId())) {
|
||||
throw new RuntimeException("重复退款的钱包不一致");
|
||||
}
|
||||
if (!Objects.equals(existing.getRefundTotalPrice(), dto.getPrice())) {
|
||||
throw new RuntimeException("重复退款的金额不一致");
|
||||
}
|
||||
return BeanUtils.toBean(existing, WalletRechargeVo.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关系服务异常时直接拒绝请求,代充值授权不能降级为放行。
|
||||
*/
|
||||
@@ -403,12 +454,14 @@ public class WalletRechargeServiceImpl extends ServiceImpl<WalletRechargeMapper,
|
||||
item.setWardName(displayName(ward));
|
||||
item.setWardAvatar(ward == null ? null : ward.getAvatar());
|
||||
if (wallet == null) {
|
||||
item.setWalletOpened(false);
|
||||
item.setBalance(0);
|
||||
item.setCardStatusName("未开通");
|
||||
item.setRechargeEnabled(false);
|
||||
item.setDisabledReason("未开通饭卡");
|
||||
return item;
|
||||
}
|
||||
item.setWalletOpened(true);
|
||||
WalletTypeEntity walletType = walletTypeService.getById(wallet.getWalletTypeId());
|
||||
item.setWalletTypeName(walletType == null ? WalletTypeEnum.EAT.getDesc() : walletType.getName());
|
||||
item.setBalance(ObjectUtil.defaultIfNull(wallet.getBalance(), 0));
|
||||
|
||||
+17
-1
@@ -10,6 +10,7 @@
|
||||
<result property="title" column="title"/>
|
||||
<result property="price" column="price"/>
|
||||
<result property="balance" column="balance"/>
|
||||
<result property="tags" column="tags"/>
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectA">
|
||||
@@ -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 @@
|
||||
<if test="dto.bizType != null">
|
||||
and a.biz_type = #{dto.bizType}
|
||||
</if>
|
||||
<if test="dto.tagList != null and dto.tagList.size() != 0">
|
||||
and (
|
||||
<foreach collection="dto.tagList" item="tag" separator=" or ">
|
||||
FIND_IN_SET(#{tag}, a.tags) > 0
|
||||
</foreach>
|
||||
)
|
||||
</if>
|
||||
<if test="dto.deptId != null">
|
||||
and a.dept_id = #{dto.deptId}
|
||||
</if>
|
||||
@@ -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)
|
||||
<if test="dto.tagList != null and dto.tagList.size() != 0">
|
||||
and (
|
||||
<foreach collection="dto.tagList" item="tag" separator=" or ">
|
||||
FIND_IN_SET(#{tag}, t.tags) > 0
|
||||
</foreach>
|
||||
)
|
||||
</if>
|
||||
order by v.mchId, v.deptId
|
||||
</select>
|
||||
|
||||
@@ -303,4 +319,4 @@
|
||||
</where>
|
||||
)
|
||||
</select>
|
||||
</mapper>
|
||||
</mapper>
|
||||
+10
-1
@@ -9,6 +9,7 @@
|
||||
<result property="applyId" column="apply_id"/>
|
||||
<result property="beforePrice" column="before_price"/>
|
||||
<result property="price" column="price"/>
|
||||
<result property="tags" column="tags"/>
|
||||
<result property="createBy" column="create_by"/>
|
||||
<result property="createTime" column="create_time"/>
|
||||
<result property="updateBy" column="update_by"/>
|
||||
@@ -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 @@
|
||||
<if test="dto.createTimeList != null and dto.createTimeList.length == 2">
|
||||
and a.create_time between #{dto.createTimeList[0]} and #{dto.createTimeList[1]}
|
||||
</if>
|
||||
<if test="dto.tagList != null and dto.tagList.size() != 0">
|
||||
and (
|
||||
<foreach collection="dto.tagList" item="tag" separator=" or ">
|
||||
FIND_IN_SET(#{tag}, a.tags) > 0
|
||||
</foreach>
|
||||
)
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<sql id="selectJoin">
|
||||
@@ -134,4 +143,4 @@
|
||||
</if>
|
||||
</where>
|
||||
</select>
|
||||
</mapper>
|
||||
</mapper>
|
||||
+131
@@ -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<MchAutoRefundConfigEntity> 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<MchAutoRefundConfigEntity> 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<MchAutoRefundConfigEntity> 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;
|
||||
}
|
||||
}
|
||||
+220
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user