l+`?o)^ASCecPu(tS#i{!eGUJsKbd%}i#S_}
zli#H?g8`YBL(3FI*;0X8qd={|gqwab-F^i0DYxrf{fx+lT+2j%5%eysCiwZQ`$n)%
z4<=sZYP+n;2m_BCc}vG!WEw)_apn$XkE+YKll}(h9}<`d*GlAVCvtt^%x3S+WB(5}
zU7V)#M~|J&V{mVjfGZ=&JUA`kJp>2*kVgF
yuT+{LJ@yQMNrs0M5ik_HlECh{&4S*=iEvVGCKR1*fla-5&
z9g>XM4P&xcvJE1R4M)*0$t4<9TNk-&Mor$+3!o6Mm-hpfa{N1Zay1Q;XNwyj;C@LETmQ!l-&}
zW{TE=@!{f-_l`2k&mJxL-gt~%EcOGy8zgn@N~avyvl+bcy+#_FH2~dhrgC2yk4392<5yivgg(uz_6a>F9wt0?Aa
z8W~puD?^7P^_P#4p^>VsN4_i10W%o(AeG9M@Nk^FFCM~j1o7xiRrww7u)+%EMa#|e
z6Pg~|`6H8b42h`DH
AND u.staffing_type = #{query.staffingType}
+
+
+ AND EXISTS (
+ SELECT 1
+ FROM sys_user_identity user_identity
+ WHERE user_identity.user_id = u.user_id
+ AND user_identity.identity_type IN
+
+ #{identityType}
+
+ )
+
and urole.role_id IN
@@ -307,6 +319,18 @@
AND u.dept_id = #{query.deptId}
+
+
+ AND EXISTS (
+ SELECT 1
+ FROM sys_user_identity user_identity
+ WHERE user_identity.user_id = u.user_id
+ AND user_identity.identity_type IN
+
+ #{identityType}
+
+ )
+
and urole.role_id IN
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTransactionTag.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTransactionTag.java
new file mode 100644
index 000000000..0eb4aeefd
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTransactionTag.java
@@ -0,0 +1,34 @@
+package com.bsd.pjcloud.wallet.api.constant;
+
+import cn.hutool.core.util.ObjectUtil;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.util.Arrays;
+
+/**
+ * 充值流水tag
+ */
+@Getter
+@AllArgsConstructor
+public enum WalletTransactionTag {
+
+ BJZ_CZ("1", "不记账充值"),
+ ;
+
+ private final String code;
+
+ private final String desc;
+
+ public static WalletTransactionTag valueOfByCode(String code) {
+ if (ObjectUtil.isEmpty(code)) return null;
+ return Arrays.stream(values()).filter(v -> ObjectUtil.equals(code, v.getCode())).findFirst().orElse(null);
+ }
+
+ public static WalletTransactionTag descOf(String description) {
+ return Arrays.stream(WalletTransactionTag.values())
+ .filter(v -> ObjectUtil.equal(v.getDesc(), description))
+ .findFirst().orElse(null);
+ }
+
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTypeEnum.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTypeEnum.java
index e71c525bb..7630e505e 100644
--- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTypeEnum.java
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletTypeEnum.java
@@ -23,12 +23,12 @@ public enum WalletTypeEnum {
public static WalletTypeEnum valueOfByCode(String code) {
if (ObjectUtil.isEmpty(code)) return null;
- return Arrays.stream(values()).filter(v -> ObjectUtil.equals(code, v.getCode())).findFirst().get();
+ return Arrays.stream(values()).filter(v -> ObjectUtil.equals(code, v.getCode())).findFirst().orElse(null);
}
public static WalletTypeEnum descOf(String description) {
return Arrays.stream(WalletTypeEnum.values())
.filter(v -> ObjectUtil.equal(v.getDesc(), description))
- .findFirst().get();
+ .findFirst().orElse(null);
}
}
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletBalanceTransactionDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletBalanceTransactionDto.java
index 2e7d0e0f8..5faf255e1 100644
--- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletBalanceTransactionDto.java
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletBalanceTransactionDto.java
@@ -32,4 +32,9 @@ public class WalletBalanceTransactionDto {
@NotNull(message = "是否暴力消费")
private Boolean force = false;
+ /**
+ * 财务统计标签字典值,多个值使用英文逗号分隔;未传时不为流水设置标签。
+ */
+ private String tags;
+
}
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionDto.java
index 3fdf245f4..4a98183d6 100644
--- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionDto.java
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionDto.java
@@ -39,6 +39,11 @@ public class WalletTransactionDto extends WalletTransactionEntity implements Bas
private List bizTypes;
+ /**
+ * 财务统计标签筛选值;传入多个字典值时匹配包含任一标签的流水。
+ */
+ private List tagList;
+
private LocalDate[] createTimeList;
/**
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeImportDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeImportDto.java
index 0220b4202..32b451d62 100644
--- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeImportDto.java
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeImportDto.java
@@ -18,6 +18,11 @@ public class WalletRechargeImportDto extends WalletRechargeImportEntity implemen
private List userUniqueIds;
+ /**
+ * 财务统计标签筛选值;传入多个字典值时匹配包含任一标签的导入记录。
+ */
+ private List tagList;
+
private Long userDeptId;
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletRechargeExcelDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletRechargeExcelDto.java
index 6697c9dfa..577256eb1 100644
--- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletRechargeExcelDto.java
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletRechargeExcelDto.java
@@ -6,6 +6,7 @@ import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.alibaba.excel.annotation.write.style.ContentRowHeight;
import com.alibaba.excel.annotation.write.style.HeadRowHeight;
+import com.bsd.pjcloud.common.excel.annotation.ExcelDict;
import com.bsd.pjcloud.common.excel.annotation.ExcelLine;
import lombok.AllArgsConstructor;
import lombok.Builder;
@@ -42,6 +43,20 @@ public class WalletRechargeExcelDto {
@ExcelProperty("操作金额")
private String price;
+ /**
+ * Excel 中展示和填写的财务标签名称;支持使用英文逗号分隔多个 {@code wallet_transaction_tag} 字典标签。
+ */
+ @ColumnWidth(100)
+ @ExcelProperty("操作标签")
+ @ExcelDict(value = "wallet_transaction_tag", multiple = true)
+ private String tag;
+
+ /**
+ * 导入校验后解析出的财务标签字典值,多个值使用英文逗号分隔,不直接读取 Excel。
+ */
+ @ExcelIgnore
+ private String tags;
+
// 操作余额
private Integer priceNumber;
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java
index 55d771e39..7e0529e89 100644
--- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java
@@ -78,6 +78,12 @@ public class WalletTransactionEntity extends Model {
@Schema(description = "余额, 单位分")
private Integer balance;
+ /**
+ * 财务统计标签,保存 {@code wallet_transaction_tag} 字典值;多个值使用英文逗号分隔,历史流水可为空。
+ */
+ @Schema(description = "财务统计标签,多个字典值使用英文逗号分隔")
+ private String tags;
+
/**
* 创建人
*/
@@ -119,4 +125,4 @@ public class WalletTransactionEntity extends Model {
*/
@Schema(description = "租户ID")
private Long tenantId;
-}
\ No newline at end of file
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeImportEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeImportEntity.java
index fc35e52fc..d006d8dd1 100644
--- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeImportEntity.java
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeImportEntity.java
@@ -67,6 +67,12 @@ public class WalletRechargeImportEntity extends Model {
+ R> result = remoteDictService.getDictByType(dictType);
+ if (result == null || !result.isOk() || result.getData() == null) {
+ return Collections.emptyList();
+ }
+ return result.getData().stream().map(SysDictItem::getLabel).toList();
+ };
+ }
+
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java
index d9498856d..e69a7a857 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java
@@ -56,7 +56,7 @@ public class WalletTransactionController {
@Inner
@PostMapping("/add-balance")
public R addWalletBalance(@Valid @RequestBody WalletBalanceTransactionDto dto) {
- WalletTransactionEntity walletTransaction = walletTransactionService.addWalletBalance(dto.getWalletId(), dto.getPrice(), dto.getBizId(), dto.getBizType());
+ WalletTransactionEntity walletTransaction = walletTransactionService.addWalletBalance(dto.getWalletId(), dto.getPrice(), dto.getBizId(), dto.getBizType(), dto.getTags());
if (ObjectUtil.isEmpty(walletTransaction)) return R.failed("增加钱包余额错误");
return R.ok(walletTransaction);
}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletTransactionMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletTransactionMapper.java
index 24e1d0a85..225e311f0 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletTransactionMapper.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletTransactionMapper.java
@@ -61,6 +61,20 @@ public interface WalletTransactionMapper extends PjcloudBaseMapper {
+ boolean first = true;
+ for (String tag : dto.getTagList()) {
+ if (first) {
+ nested.apply("FIND_IN_SET({0}, tags) > 0", tag);
+ first = false;
+ }
+ else {
+ nested.or().apply("FIND_IN_SET({0}, tags) > 0", tag);
+ }
+ }
+ });
+ }
return wrapper;
}
@@ -98,5 +112,11 @@ public interface WalletTransactionMapper extends PjcloudBaseMapper sumAndCount(@Param("dto") WalletTransactionSumAndCountDto dto);
- List exportCzTotalPerson();
-}
\ No newline at end of file
+ /**
+ * 查询固定金额范围内的个人充值统计明细,并按财务标签筛选。
+ *
+ * @param dto 充值统计筛选条件;标签多选时匹配包含任一标签的流水
+ * @return 个人充值统计导出明细
+ */
+ List exportCzTotalPerson(@Param("dto") WalletTransactionDto dto);
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java
index 3f6543476..7d3eeb85b 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java
@@ -69,6 +69,18 @@ public interface WalletTransactionService extends IService list);
/**
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java
index 6e1d01284..36cefd52a 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java
@@ -212,6 +212,22 @@ public class WalletTransactionServiceImpl extends ServiceImpl list = baseMapper.exportCzTotalPerson();
+ List list = baseMapper.exportCzTotalPerson(dto);
WriteSheet writeSheet = EasyExcel.writerSheet().build();
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java
index e4a2f74ed..a755fa87b 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java
@@ -12,8 +12,10 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.admin.api.entity.SysDept;
+import com.bsd.pjcloud.admin.api.entity.SysDictItem;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
+import com.bsd.pjcloud.admin.api.feign.RemoteDictService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.admin.api.utils.DataUtil;
import com.bsd.pjcloud.admin.api.vo.UserVO;
@@ -90,6 +92,11 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl batchSaveList = list.stream().map(v -> WalletRechargeImportEntity.builder().price(v.getPriceNumber()).userId(v.getUserId()).userDeptId(v.getUserDeptId()).build()).toList();
+ List batchSaveList = list.stream().map(v -> WalletRechargeImportEntity.builder().price(v.getPriceNumber()).userId(v.getUserId()).userDeptId(v.getUserDeptId()).tags(v.getTags()).build()).toList();
batchSaveList.forEach(v -> v.setApplyId(insertObj.getId()));
rechargeImportService.saveBatch(batchSaveList);
return R.ok(null, "导入成功");
@@ -349,7 +357,7 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl batchSaveList = list.stream().map(v -> WalletRechargeImportEntity.builder().price(0).userId(v.getUserId()).userDeptId(v.getUserDeptId()).build()).toList();
+ List batchSaveList = list.stream().map(v -> WalletRechargeImportEntity.builder().price(0).userId(v.getUserId()).userDeptId(v.getUserDeptId()).tags(v.getTags()).build()).toList();
batchSaveList.forEach(v -> v.setApplyId(insertObj.getId()));
rechargeImportService.saveBatch(batchSaveList);
return R.ok(null, "导入成功");
@@ -371,7 +379,7 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl batchSaveList = list.stream().map(v -> WalletRechargeImportEntity.builder().price(v.getPriceNumber()).userId(v.getUserId()).userDeptId(v.getUserDeptId()).invoiceId(invoice.getId()).build()).toList();
+ List batchSaveList = list.stream().map(v -> WalletRechargeImportEntity.builder().price(v.getPriceNumber()).userId(v.getUserId()).userDeptId(v.getUserDeptId()).invoiceId(invoice.getId()).tags(v.getTags()).build()).toList();
rechargeImportService.saveBatch(batchSaveList);
return R.ok(null, "导入成功");
}
@@ -388,6 +396,7 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl errorMessageList = (List) bindingResult.getTarget();
+ resolveTransactionTagValues(list, errorMessageList);
List usernameList = list.stream().map(WalletRechargeExcelDto::getUsername).toList();
List userPoneList = list.stream().map(WalletRechargeExcelDto::getUserMobile).toList();
for (WalletRechargeExcelDto v : list) {
@@ -442,6 +451,41 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl list, List errorMessageList) {
+ List taggedRows = list.stream().filter(item -> ObjectUtil.isNotEmpty(item.getTag())).toList();
+ if (taggedRows.isEmpty()) {
+ return;
+ }
+ R> result = remoteDictService.getDictByType(TRANSACTION_TAG_DICT_TYPE);
+ if (result == null || !result.isOk() || ObjectUtil.isEmpty(result.getData())) {
+ taggedRows.forEach(item -> errorMessageList.add(new ErrorMessage(item.getLineNum(), Set.of("操作标签字典查询失败"))));
+ return;
+ }
+ Map valueByLabel = result.getData().stream()
+ .filter(item -> ObjectUtil.isNotEmpty(item.getLabel()) && ObjectUtil.isNotEmpty(item.getItemValue()))
+ .collect(java.util.stream.Collectors.toMap(SysDictItem::getLabel, SysDictItem::getItemValue, (first, ignored) -> first));
+ for (WalletRechargeExcelDto item : taggedRows) {
+ List tagValues = Arrays.stream(item.getTag().split(","))
+ .map(String::trim)
+ .distinct()
+ .map(valueByLabel::get)
+ .filter(Objects::nonNull)
+ .toList();
+ long selectedTagCount = Arrays.stream(item.getTag().split(",")).map(String::trim).distinct().count();
+ if (tagValues.size() != selectedTagCount) {
+ errorMessageList.add(new ErrorMessage(item.getLineNum(), Set.of("操作标签包含无法映射的字典项")));
+ continue;
+ }
+ item.setTags(String.join(",", tagValues));
+ }
+ }
+
/* ************************************* end excel end ************************************* */
@Override
@@ -497,7 +541,7 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl userIds = importList.stream().map(WalletRechargeImportEntity::getUserId).distinct().toList();
@@ -548,7 +592,7 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl
+
@@ -22,6 +23,7 @@
a.title,
a.price,
a.balance,
+ a.tags,
a.create_by,
a.create_time,
a.update_by,
@@ -44,6 +46,13 @@
and a.biz_type = #{dto.bizType}
+
+ and (
+
+ FIND_IN_SET(#{tag}, a.tags) > 0
+
+ )
+
and a.dept_id = #{dto.deptId}
@@ -286,6 +295,13 @@
and t.biz_type in ('1', '2', '3')
and t.price in (80000, 160000, 240000)
and t.dept_id not in (1875013738914156546, 1910218102049226753, 1910218128624336898, 1942046316044144642)
+
+ and (
+
+ FIND_IN_SET(#{tag}, t.tags) > 0
+
+ )
+
order by v.mchId, v.deptId
@@ -303,4 +319,4 @@
)
-
\ No newline at end of file
+
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeImportMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeImportMapper.xml
index 2d7394b36..086f0d75b 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeImportMapper.xml
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeImportMapper.xml
@@ -9,6 +9,7 @@
+
@@ -24,6 +25,7 @@
a.apply_id,
a.before_price,
a.price,
+ a.tags,
a.create_by,
a.create_time,
a.update_by,
@@ -71,6 +73,13 @@
and a.create_time between #{dto.createTimeList[0]} and #{dto.createTimeList[1]}
+
+ and (
+
+ FIND_IN_SET(#{tag}, a.tags) > 0
+
+ )
+
@@ -134,4 +143,4 @@
-
\ No newline at end of file
+
From 10443065584ef9763142643705f2065ceb2dd410 Mon Sep 17 00:00:00 2001
From: dzc <2388224167@qq.com>
Date: Thu, 13 Aug 2026 17:49:09 +0800
Subject: [PATCH 2/3] =?UTF-8?q?feat:=E5=8F=AA=E6=9C=89=E7=AE=A1=E7=90=86?=
=?UTF-8?q?=E5=91=98=E6=89=8D=E8=83=BD=E6=93=8D=E4=BD=9C=E7=94=A8=E6=88=B7?=
=?UTF-8?q?=E8=A7=92=E8=89=B2=EF=BC=8C=E6=99=AE=E9=80=9A=E7=94=A8=E6=88=B7?=
=?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=9A=84=E7=94=A8=E6=88=B7=E9=BB=98=E8=AE=A4?=
=?UTF-8?q?=E6=99=AE=E9=80=9A=E7=94=A8=E6=88=B7=E5=92=8C=E8=87=AA=E5=8A=A8?=
=?UTF-8?q?=E5=88=9B=E5=BB=BA=E7=82=B9=E5=87=BB=E9=92=B1=E5=8C=85=E6=9D=83?=
=?UTF-8?q?=E9=99=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../service/impl/SysUserServiceImpl.java | 69 +++++++++++++++++--
1 file changed, 62 insertions(+), 7 deletions(-)
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java
index 69e422f41..c43e6d18f 100644
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java
@@ -192,6 +192,12 @@ public class SysUserServiceImpl extends ServiceImpl impl
/**
* 保存用户主表、身份、岗位和角色关系,并按审核结果安排后续钱包创建。
+ * 只有当前上下文明确定义了 {@code ROLE_ADMIN} 时才保留请求角色;其他操作人以及无认证上下文的调用,
+ * 请求角色都会被固定替换为普通用户和自动创建钱包角色。免审参数只控制审批流程,不放宽角色权限。
+ *
+ * @param userDto 用户创建参数;普通后台创建场景中的角色可能被安全规则覆盖
+ * @param skipAudit 是否跳过新增用户审批,不影响角色选择权限
+ * @return 新建用户ID
*/
private Long saveUser(UserDTO userDto, boolean skipAudit) {
sysUserIdentityService.validateDto(userDto);
@@ -199,21 +205,21 @@ public class SysUserServiceImpl extends ServiceImpl impl
BeanUtils.copyProperties(userDto, sysUser);
// 是否进入审批由当前操作人的专用角色控制,不能再通过部门管理员身份推断。
PjcloudUser applyUser = SecurityUtils.getUser();
+ List applyUserRoles = applyUser == null ? Collections.emptyList() : sysRoleService.findRolesByUserId(applyUser.getId());
+ boolean isRoleAdmin = CollUtil.isNotEmpty(applyUserRoles) && applyUserRoles.stream()
+ .anyMatch(role -> ObjectUtil.equals(role.getRoleCode(), CommonRoleConstant.ROLE_ADMIN));
+ if (!isRoleAdmin) {
+ // 请求中的角色ID不可信,只有明确具有管理员角色的操作人才能自选新增用户角色。
+ userDto.setRole(getNonAdminNewUserRoleIds());
+ }
boolean needApproval = false;
if (skipAudit) {
sysUser.setProcessStatus(TaskCheckStatusEnum.RESULT.getCode());
sysUser.setProcessTime(LocalDateTime.now());
} else if (null != applyUser) {
- List applyUserRoles = sysRoleService.findRolesByUserId(applyUser.getId());
needApproval = CollUtil.isNotEmpty(applyUserRoles) && applyUserRoles.stream()
.anyMatch(role -> ObjectUtil.equals(role.getRoleCode(), CommonRoleConstant.ADD_USER_NEED_CEHCK));
if (needApproval) {
- List defaultRoles = sysRoleService.findRolesByCodes(List.of(CommonRoleConstant.DEFAULT_USER, CommonRoleConstant.AUTO_CREATE_WALLET));
- if (ObjectUtil.isEmpty(defaultRoles)) {
- userDto.setRole(null);
- } else {
- userDto.setRole(defaultRoles.stream().map(SysRole::getRoleId).toList());
- }
userDto.setDeptIds(null);
sysUser.setProcessStatus(TaskCheckStatusEnum.PADDING.getCode());
} else {
@@ -267,6 +273,22 @@ public class SysUserServiceImpl extends ServiceImpl impl
return sysUser.getUserId();
}
+ /**
+ * 查询非管理员新增用户时必须赋予的固定角色ID。
+ *
+ * @return 按普通用户、自动创建钱包顺序排列的角色ID
+ * @throws IllegalStateException 任一固定角色未在当前租户中配置时抛出
+ */
+ private List getNonAdminNewUserRoleIds() {
+ List roleCodes = List.of(CommonRoleConstant.DEFAULT_USER, CommonRoleConstant.AUTO_CREATE_WALLET);
+ List defaultRoles = Optional.ofNullable(sysRoleService.findRolesByCodes(roleCodes)).orElseGet(Collections::emptyList);
+ Map roleIdByCode = defaultRoles.stream().collect(Collectors.toMap(SysRole::getRoleCode, SysRole::getRoleId, (first, ignored) -> first));
+ if (!roleIdByCode.keySet().containsAll(roleCodes)) {
+ throw new IllegalStateException("默认用户角色配置不完整,请联系管理员");
+ }
+ return roleCodes.stream().map(roleIdByCode::get).toList();
+ }
+
/**
* 通过查用户的全部信息
*
@@ -477,6 +499,14 @@ public class SysUserServiceImpl extends ServiceImpl impl
return R.ok(this.updateById(sysUser));
}
+ /**
+ * 更新指定用户的基础资料、身份、岗位和角色关系。
+ * 当前操作人只有具备 {@code ROLE_ADMIN} 角色时才能改变目标用户的角色集合;非管理员提交的角色与库中不一致时直接拒绝,
+ * 一致时忽略该字段并保留原角色关系。
+ *
+ * @param userDto 用户更新参数,必须包含可解析且已存在的用户ID
+ * @return 用户资料及允许变更的关联信息处理完成后返回 {@code true}
+ */
@Override
@Transactional(rollbackFor = Exception.class)
@Audit(name = "用户更新", spel = "@sysUserMapper.selectById(#userDto.userId)")
@@ -493,6 +523,7 @@ public class SysUserServiceImpl extends ServiceImpl impl
// 以库中主键为准,避免前端 Long 精度丢失后 ne 条件排除失败
userId = dbUser.getUserId();
userDto.setUserId(userId);
+ protectUserRolesFromUnauthorizedUpdate(userDto, userId);
sysUserIdentityService.validateDto(userDto);
// 更新用户表信息
SysUser sysUser = new SysUser();
@@ -714,6 +745,30 @@ public class SysUserServiceImpl extends ServiceImpl impl
return true;
}
+ /**
+ * 校验并保护已有用户的角色关系,避免非管理员绕过前端直接篡改角色。
+ *
+ * @param userDto 本次用户更新参数;角色为空表示调用方不更新角色
+ * @param userId 库中确认存在的目标用户ID
+ */
+ private void protectUserRolesFromUnauthorizedUpdate(UserDTO userDto, Long userId) {
+ if (userDto.getRole() == null) {
+ return;
+ }
+ PjcloudUser operator = SecurityUtils.getUser();
+ boolean isRoleAdmin = operator != null && sysRoleService.findRolesByUserId(operator.getId()).stream().anyMatch(role -> ObjectUtil.equals(role.getRoleCode(), CommonRoleConstant.ROLE_ADMIN));
+ if (isRoleAdmin) {
+ return;
+ }
+ Set currentRoleIds = sysUserRoleMapper.selectList(Wrappers.lambdaQuery().eq(SysUserRole::getUserId, userId)).stream().map(SysUserRole::getRoleId).collect(Collectors.toSet());
+ Set submittedRoleIds = new HashSet<>(userDto.getRole());
+ if (!currentRoleIds.equals(submittedRoleIds)) {
+ throw new RuntimeException("仅管理员可以修改用户角色");
+ }
+ // 非管理员提交的角色仅用于一致性校验,校验通过后也不得重写角色关系。
+ userDto.setRole(null);
+ }
+
/**
* 锁定用户
*
From 6e1af7c84d84e4ff6bf973c0448fff96d861fca4 Mon Sep 17 00:00:00 2001
From: dzc <2388224167@qq.com>
Date: Fri, 14 Aug 2026 15:03:58 +0800
Subject: [PATCH 3/3] =?UTF-8?q?fix:=E4=BF=AE=E6=94=B9=E7=94=A8=E6=88=B7?=
=?UTF-8?q?=E7=AE=A1=E7=90=86=E6=A0=B7=E5=BC=8F=EF=BC=8C=E8=83=BD=E5=B1=95?=
=?UTF-8?q?=E7=A4=BA=E7=94=A8=E6=88=B7=E8=BA=AB=E4=BB=BD=E4=BF=A1=E6=81=AF?=
=?UTF-8?q?=E3=80=82=20fix:=E4=BF=AE=E6=94=B9=E6=A0=91=E7=8A=B6=E6=9F=A5?=
=?UTF-8?q?=E8=AF=A2=E8=AE=A9=E4=BB=96=E6=9B=B4=E7=B4=A7=E5=87=91=E3=80=82?=
=?UTF-8?q?=20fix:=E4=BF=AE=E6=94=B9app=E5=BE=AE=E4=BF=A1=E7=99=BB?=
=?UTF-8?q?=E5=BD=95=EF=BC=8C=E5=85=88=E6=A3=80=E6=B5=8B=E7=94=A8=E6=88=B7?=
=?UTF-8?q?=E6=89=8B=E6=9C=BA=E4=B8=8A=E6=B2=A1=E6=9C=89=E6=9C=89=E8=A3=85?=
=?UTF-8?q?=E5=BE=AE=E4=BF=A1=EF=BC=8C=E6=B2=A1=E6=9C=89=E5=B0=B1=E9=9A=90?=
=?UTF-8?q?=E8=97=8F=E5=BE=AE=E4=BF=A1=E7=99=BB=E5=BD=95=E3=80=82=20fix:?=
=?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=95=86=E6=88=B7=E7=AE=A1=E7=90=86"?=
=?UTF-8?q?=E5=95=86=E6=88=B7=E5=8A=9F=E8=83=BD"=E6=A0=B7=E5=BC=8F=20feat:?=
=?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=87=AA=E5=8A=A8=E9=80=80=E6=AC=BE=E6=A8=A1?=
=?UTF-8?q?=E5=9D=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
db/20260814_add_hard_auto_refund.sql | 148 +++
.../refund/HardAfterSaleOperateTypeEnum.java | 34 +-
.../HardAutoRefundAttemptResultEnum.java | 51 +
.../refund/HardAutoRefundTaskStatusEnum.java | 73 ++
.../refund/HardAutoRefundTriggerTypeEnum.java | 53 +
.../dto/order/HardAutoRefundTaskPageDto.java | 57 +
...ardAppCreateDailyMenuReserveOrdersDto.java | 6 +-
.../app/HardAppCreateReserveOrdersDto.java | 7 +
.../dto/order/app/HardAppRefundApplyDto.java | 2 +-
.../order/HardAutoRefundAttemptLogEntity.java | 141 +++
.../order/HardAutoRefundTaskEntity.java | 159 +++
.../api/entity/order/HardOrderEntity.java | 18 +
.../hard/api/vo/order/HardAfterSaleVo.java | 5 +
.../api/vo/order/HardAutoRefundTaskVo.java | 45 +
.../vo/order/app/HardAppAutoRefundTaskVo.java | 44 +
.../vo/order/app/HardAppRefundApplyVo.java | 36 +
.../order/HardAutoRefundTaskController.java | 155 +++
.../controller/order/HardOrderController.java | 4 +-
.../hard/job/HardAutoRefundTaskJob.java | 57 +
.../order/HardAutoRefundAttemptLogMapper.java | 12 +
.../order/HardAutoRefundTaskMapper.java | 51 +
.../hard/service/HardAfterSaleService.java | 8 +
.../service/HardApptReservationService.java | 13 +-
.../service/HardAutoRefundTaskService.java | 110 ++
.../impl/HardApptReservationServiceImpl.java | 4 +-
.../impl/order/HardAfterSaleServiceImpl.java | 159 ++-
.../order/HardAutoRefundTaskServiceImpl.java | 972 ++++++++++++++++++
.../impl/order/HardOrderServiceImpl.java | 379 +++++--
.../mapper/order/HardOrderMapper.xml | 3 +
.../hard/job/HardAutoRefundTaskJobTest.java | 62 ++
.../HardApptReservationServiceImplTest.java | 6 +-
.../HardAutoRefundTaskServiceImplTest.java | 180 ++++
.../2026-06/debug.2026-06-01.0.log.gz | Bin 0 -> 4210 bytes
.../admin/service/SysUserIdentityService.java | 23 +-
.../service/impl/SysUserServiceImpl.java | 9 +-
.../impl/user/SysUserIdentityServiceImpl.java | 65 +-
.../mch/MchAutoRefundRuleTypeEnum.java | 49 +
.../mch/MchAutoRefundConfigBatchQueryDto.java | 30 +
.../api/dto/mch/MchAutoRefundConfigDto.java | 54 +
.../dto/recharge/WalletRechargeRefundDto.java | 26 +
.../entity/mch/MchAutoRefundConfigEntity.java | 111 ++
.../RemoteMchAutoRefundConfigService.java | 42 +
.../feign/RemoteWalletRechargeService.java | 19 +-
.../wallet/api/feign/RemoteWalletService.java | 12 +
.../feign/RemoteWalletTransactionService.java | 21 +
.../api/vo/mch/MchAutoRefundConfigVo.java | 15 +
.../app/GuardianWardRechargeItemVo.java | 5 +
...mework.cloud.openfeign.FeignClient.imports | 1 +
.../controller/WalletManageController.java | 14 +
.../WalletTransactionController.java | 25 +
.../mch/MchAutoRefundConfigController.java | 96 ++
.../recharge/WalletRechargeController.java | 16 +
.../wallet/mapper/WalletManageMapper.java | 5 +-
.../mapper/mch/MchAutoRefundConfigMapper.java | 39 +
.../service/MchAutoRefundConfigService.java | 65 ++
.../wallet/service/WalletManageService.java | 11 +-
.../wallet/service/WalletRechargeService.java | 12 +-
.../service/WalletTransactionService.java | 17 +
.../service/impl/WalletManageServiceImpl.java | 27 +-
.../impl/WalletTransactionServiceImpl.java | 353 +++++++
.../mch/MchAutoRefundConfigServiceImpl.java | 254 +++++
.../recharge/WalletRechargeServiceImpl.java | 61 +-
.../MchAutoRefundConfigServiceImplTest.java | 131 +++
.../WalletRechargeServiceImplTest.java | 220 ++++
64 files changed, 4748 insertions(+), 134 deletions(-)
create mode 100644 db/20260814_add_hard_auto_refund.sql
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundAttemptResultEnum.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskStatusEnum.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAutoRefundTaskPageDto.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundAttemptLogEntity.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppAutoRefundTaskVo.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJob.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundAttemptLogMapper.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJobTest.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java
create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/logs/pjcloud-thirdparty-biz/2026-06/debug.2026-06-01.0.log.gz
create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/mch/MchAutoRefundRuleTypeEnum.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigBatchQueryDto.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigDto.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchAutoRefundConfigEntity.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteMchAutoRefundConfigService.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchAutoRefundConfigVo.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchAutoRefundConfigController.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchAutoRefundConfigMapper.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchAutoRefundConfigService.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchAutoRefundConfigServiceImpl.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchAutoRefundConfigServiceImplTest.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImplTest.java
diff --git a/db/20260814_add_hard_auto_refund.sql b/db/20260814_add_hard_auto_refund.sql
new file mode 100644
index 000000000..80a960506
--- /dev/null
+++ b/db/20260814_add_hard_auto_refund.sql
@@ -0,0 +1,148 @@
+-- 每日菜谱预约订单自动退款。
+-- 本脚本只新增自动退款所需表、订单快照字段、钱包幂等索引和技术重试权限点,
+-- 不回填历史订单,不创建跨库外键,也不自动向已有角色授予技术重试权限。
+
+-- 执行唯一索引变更前应确认本查询返回 0 行;发现重复时必须先人工核对资金记录,禁止自动清理。
+SELECT pay_refund_order_id, COUNT(*) AS duplicate_count
+FROM pjcloudx_wallet.wallet_recharge
+WHERE pay_refund_order_id IS NOT NULL
+GROUP BY pay_refund_order_id
+HAVING COUNT(*) > 1;
+
+-- Wallet按售后单ID保证退款到账幂等;MySQL唯一索引允许保留多条NULL历史记录。
+-- 将该变更放在其他DDL之前,重复检测不通过时由唯一索引直接阻止脚本继续执行。
+ALTER TABLE `pjcloudx_wallet`.`wallet_recharge`
+ ADD UNIQUE KEY `uk_wallet_recharge_refund_order` (`pay_refund_order_id`);
+
+-- 商户自动退款配置。父店和分店按各自 mch_id 独立配置,每个订单类型只保留一条配置记录。
+CREATE TABLE `pjcloudx_wallet`.`mch_auto_refund_config`
+(
+ `id` bigint(20) NOT NULL COMMENT '配置主键,由应用使用雪花算法生成',
+ `mch_id` bigint(20) NOT NULL COMMENT '商户ID,父店和分店互不继承配置',
+ `order_type` char(10) NOT NULL COMMENT '硬件订单类型编码,首期仅支持2预约订单',
+ `enabled` char(1) NOT NULL DEFAULT '0' COMMENT '自动退款开关,0关闭,1开启',
+ `rule_type` varchar(32) NOT NULL COMMENT '自动退款规则,PAY_TIME_LIMIT支付后限时,APPT_CYCLE_CLOSE周期预约截止',
+ `pay_time_limit_hours` int(11) DEFAULT NULL COMMENT '支付后可自动退款小时数,仅PAY_TIME_LIMIT使用,业务允许1至168',
+ `create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
+ `update_time` datetime DEFAULT NULL COMMENT '修改时间',
+ `del_flag` char(1) DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除;本功能不提供删除接口',
+ `tenant_id` bigint(20) NOT NULL COMMENT '租户ID',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_mch_auto_refund_config` (`tenant_id`, `mch_id`, `order_type`),
+ KEY `idx_mch_auto_refund_config_mch` (`tenant_id`, `mch_id`, `del_flag`)
+) ENGINE = InnoDB
+ DEFAULT CHARSET = utf8mb4
+ COLLATE = utf8mb4_general_ci
+ COMMENT = '商户自动退款配置';
+
+-- 订单保存下单时的配置与规则快照;三个关键字段任一为空均不具备免审资格,历史订单保持为空。
+ALTER TABLE `pjcloudx_hardware`.`hard_order`
+ ADD COLUMN `auto_refund_config_id` bigint(20) DEFAULT NULL
+ COMMENT '下单时命中的Wallet自动退款配置ID,为空表示不具备免审资格' AFTER `appt_rule_id`,
+ ADD COLUMN `auto_refund_rule_type` varchar(32) DEFAULT NULL
+ COMMENT '下单时自动退款规则快照,PAY_TIME_LIMIT或APPT_CYCLE_CLOSE' AFTER `auto_refund_config_id`,
+ ADD COLUMN `auto_refund_deadline` datetime DEFAULT NULL
+ COMMENT '免审退款申请截止时间,闭区间判断为申请时间小于等于该值' AFTER `auto_refund_rule_type`;
+
+-- 自动退款持久化任务。一个售后单最多一条任务,失败后由该记录驱动定时恢复。
+CREATE TABLE `pjcloudx_hardware`.`hard_auto_refund_task`
+(
+ `id` bigint(20) NOT NULL COMMENT '任务主键,由应用使用雪花算法生成',
+ `after_sale_id` bigint(20) NOT NULL COMMENT '售后单ID,同时作为钱包退款幂等键',
+ `order_id` bigint(20) NOT NULL COMMENT '原硬件订单ID,用于订单级加锁和退款状态汇总',
+ `mch_id` bigint(20) NOT NULL COMMENT '订单所属商户ID,用于数据范围和配置复核',
+ `config_id` bigint(20) NOT NULL COMMENT '下单时命中的Wallet自动退款配置ID',
+ `rule_type` varchar(32) NOT NULL COMMENT '自动退款规则快照,任务重试期间不随配置规则变化',
+ `eligibility_deadline` datetime NOT NULL COMMENT '申请免审资格截止时间,任务受理后跨过该时间仍继续重试',
+ `status` char(10) NOT NULL COMMENT '任务状态,10待执行,20等待重试,30开关关闭暂停,31配置不可用暂停,40重试耗尽,50成功',
+ `auto_attempt_count` int(11) NOT NULL DEFAULT 0 COMMENT '已消耗自动尝试次数,含首次执行,最大8次',
+ `total_attempt_count` int(11) NOT NULL DEFAULT 0 COMMENT '全部尝试序号计数,包含自动、人工和开关关闭跳过',
+ `next_retry_time` datetime DEFAULT NULL COMMENT '下次自动重试时间,暂停、耗尽和成功状态为空',
+ `last_attempt_time` datetime DEFAULT NULL COMMENT '最近一次实际尝试或开关关闭跳过的开始时间',
+ `success_time` datetime DEFAULT NULL COMMENT '钱包到账且售后与订单状态提交成功的时间',
+ `last_error` varchar(2000) DEFAULT NULL COMMENT '最近一次失败诊断信息,不保存资金凭证等敏感数据',
+ `create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
+ `update_time` datetime DEFAULT NULL COMMENT '修改时间',
+ `del_flag` char(1) DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除;任务不提供删除接口',
+ `tenant_id` bigint(20) NOT NULL COMMENT '租户ID',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_hard_auto_refund_task_after_sale` (`after_sale_id`),
+ KEY `idx_hard_auto_refund_task_scan` (`tenant_id`, `status`, `next_retry_time`, `del_flag`),
+ KEY `idx_hard_auto_refund_task_order` (`tenant_id`, `order_id`, `del_flag`)
+) ENGINE = InnoDB
+ DEFAULT CHARSET = utf8mb4
+ COLLATE = utf8mb4_general_ci
+ COMMENT = '硬件订单自动退款任务';
+
+-- 自动退款单次尝试日志。日志只在一次尝试内从执行中更新为最终结果,完成后只读。
+CREATE TABLE `pjcloudx_hardware`.`hard_auto_refund_attempt_log`
+(
+ `id` bigint(20) NOT NULL COMMENT '日志主键,由应用使用雪花算法生成',
+ `task_id` bigint(20) NOT NULL COMMENT '自动退款任务ID',
+ `after_sale_id` bigint(20) NOT NULL COMMENT '售后单ID,用于直接查询完整尝试历史',
+ `attempt_no` int(11) NOT NULL COMMENT '任务内递增尝试序号,包含开关关闭跳过记录',
+ `trigger_type` char(10) NOT NULL COMMENT '触发来源,10首次同步执行,20定时重试,30人工技术重试',
+ `result_status` char(10) NOT NULL COMMENT '尝试结果,10执行中,20成功,30失败,40因开关关闭跳过',
+ `refund_code` varchar(128) NOT NULL COMMENT '稳定钱包退款业务号,格式hard_{orderId}_{afterSaleId}',
+ `operator_user_id` bigint(20) DEFAULT NULL COMMENT '人工技术重试操作用户ID,自动触发为空',
+ `started_time` datetime NOT NULL COMMENT '本次尝试开始时间',
+ `finished_time` datetime DEFAULT NULL COMMENT '本次尝试完成时间,执行中记录为空',
+ `error_message` varchar(2000) DEFAULT NULL COMMENT '失败诊断信息,成功或开关关闭跳过时为空',
+ `create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
+ `update_time` datetime DEFAULT NULL COMMENT '修改时间',
+ `del_flag` char(1) DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除;日志不提供删除接口',
+ `tenant_id` bigint(20) NOT NULL COMMENT '租户ID',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_hard_auto_refund_attempt` (`task_id`, `attempt_no`),
+ KEY `idx_hard_auto_refund_attempt_after_sale` (`tenant_id`, `after_sale_id`, `create_time`)
+) ENGINE = InnoDB
+ DEFAULT CHARSET = utf8mb4
+ COLLATE = utf8mb4_general_ci
+ COMMENT = '硬件订单自动退款尝试日志';
+
+-- 为每个已有“售后订单-查看”权限所在租户注册技术重试按钮。
+-- 菜单ID从迁移日期种子或现有最大ID之后分配;脚本不写sys_role_menu,需由角色管理显式授权。
+SET @hard_auto_refund_menu_id_base = (
+ SELECT GREATEST(COALESCE(MAX(menu_id), 0), 2026081400000000000)
+ FROM pjcloudx.sys_menu
+);
+
+INSERT INTO `pjcloudx`.`sys_menu`
+(`menu_id`, `name`, `permission`, `path`, `component`, `parent_id`, `icon`, `visible`, `sort_order`,
+ `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `tenant_id`)
+SELECT @hard_auto_refund_menu_id_base + ROW_NUMBER() OVER (ORDER BY source_menu.tenant_id, source_menu.menu_id),
+ '自动退款重试',
+ 'hard_autoRefundTask_retry',
+ NULL,
+ NULL,
+ source_menu.parent_id,
+ '1',
+ '1',
+ COALESCE(source_menu.sort_order, 0) + 1,
+ '0',
+ NULL,
+ source_menu.menu_type,
+ 'system',
+ NOW(),
+ 'system',
+ NOW(),
+ '0',
+ source_menu.tenant_id
+FROM pjcloudx.sys_menu source_menu
+WHERE source_menu.permission = 'hard_hardAfterSale_view'
+ AND source_menu.del_flag = '0'
+ AND NOT EXISTS (
+ SELECT 1
+ FROM pjcloudx.sys_menu existing_menu
+ WHERE existing_menu.permission = 'hard_autoRefundTask_retry'
+ AND existing_menu.tenant_id <=> source_menu.tenant_id
+ AND existing_menu.del_flag = '0'
+);
+
+SET @hard_auto_refund_menu_id_base = NULL;
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java
index 53883c3e5..6e1edc418 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java
@@ -1,26 +1,56 @@
package com.bsd.pjcloud.hard.api.constant.refund;
-import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 硬件售后操作类型的枚举
*/
-@AllArgsConstructor
@Getter
public enum HardAfterSaleOperateTypeEnum {
+ /** 会员主动提交退款申请。 */
MEMBER_CREATE("10", "会员申请退款"),
+
+ /** 商户审核同意退款申请。 */
ADMIN_AGREE_APPLY("11", "商家同意退款"),
+
+ /** 商户审核拒绝退款申请。 */
ADMIN_DISAGREE_APPLY("12", "商家拒绝退款"),
+
+ /** 会员补充退货物流信息。 */
MEMBER_DELIVERY("20", "会员填写退货物流信息,快递公司:{deliveryName},快递单号:{logisticsNo}"),
+
+ /** 商户确认收到退货商品。 */
ADMIN_AGREE_RECEIVE("21", "商家收货"),
+
+ /** 商户拒绝接收退货商品。 */
ADMIN_DISAGREE_RECEIVE("22", "商家拒绝收货,原因:{reason}"),
+
+ /** 商户或后台执行资金退款。 */
ADMIN_REFUND("30", "商家退款"),
+
+ /** 系统根据订单快照和商户配置免审完成退款。 */
+ SYSTEM_AUTO_REFUND("31", "系统自动退款"),
+
+ /** 会员取消尚未审核的退款申请。 */
MEMBER_CANCEL("40", "会员取消退款"),
;
+ /** 售后日志持久化的稳定操作类型编码。 */
private final String type;
+
+ /** 售后进度展示内容,部分人工操作允许包含格式化占位符。 */
private final String content;
+
+ /**
+ * 构造售后操作类型协议项。
+ *
+ * @param type 售后日志持久化编码
+ * @param content 售后进度展示内容
+ */
+ HardAfterSaleOperateTypeEnum(String type, String content) {
+ this.type = type;
+ this.content = content;
+ }
}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundAttemptResultEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundAttemptResultEnum.java
new file mode 100644
index 000000000..c643ddf4f
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundAttemptResultEnum.java
@@ -0,0 +1,51 @@
+package com.bsd.pjcloud.hard.api.constant.refund;
+
+import lombok.Getter;
+
+/**
+ * 自动退款单次尝试结果。日志先以执行中写入同一事务,最终持久化为成功、失败或开关关闭跳过。
+ */
+@Getter
+public enum HardAutoRefundAttemptResultEnum {
+
+ /**
+ * 资金调用开始但尚未完成的临时状态;正常提交后不会长期停留。
+ */
+ EXECUTING("10", "执行中"),
+
+ /**
+ * 钱包到账及 Hardware 状态更新均已提交。
+ */
+ SUCCESS("20", "成功"),
+
+ /**
+ * 本次实际资金调用失败,已安排重试或进入耗尽状态。
+ */
+ FAILED("30", "失败"),
+
+ /**
+ * 执行前发现当前开关关闭,未调用资金接口且不消耗自动尝试额度。
+ */
+ SWITCH_DISABLED("40", "因开关关闭跳过");
+
+ /**
+ * 数据库存储的稳定结果编码。
+ */
+ private final String status;
+
+ /**
+ * 管理端展示名称。
+ */
+ private final String description;
+
+ /**
+ * 构造一次尝试结果协议项。
+ *
+ * @param status 数据库存储的稳定结果编码
+ * @param description 管理端展示名称
+ */
+ HardAutoRefundAttemptResultEnum(String status, String description) {
+ this.status = status;
+ this.description = description;
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskStatusEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskStatusEnum.java
new file mode 100644
index 000000000..936d68ac5
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskStatusEnum.java
@@ -0,0 +1,73 @@
+package com.bsd.pjcloud.hard.api.constant.refund;
+
+import lombok.Getter;
+
+import java.util.Arrays;
+
+/**
+ * 自动退款任务状态。编码持久化到 hard_auto_refund_task.status,并由 PC、App 共同展示。
+ */
+@Getter
+public enum HardAutoRefundTaskStatusEnum {
+
+ /**
+ * 售后申请事务已经创建任务,等待事务提交后的首次同步执行。
+ */
+ PENDING("10", "待执行"),
+
+ /**
+ * 上次资金调用失败,等待 next_retry_time 到期后自动重试。
+ */
+ WAITING_RETRY("20", "等待重试"),
+
+ /**
+ * 商户已关闭自动退款,任务保留并等待重新开启。
+ */
+ SWITCH_DISABLED("30", "开关关闭暂停"),
+
+ /**
+ * 配置缺失、规则不一致或配置服务异常,任务保留并等待配置恢复。
+ */
+ CONFIG_UNAVAILABLE("31", "配置不可用暂停"),
+
+ /**
+ * 首次执行和七次自动重试均失败,只允许技术人员额外手工触发。
+ */
+ RETRY_EXHAUSTED("40", "重试耗尽"),
+
+ /**
+ * 钱包已幂等到账,售后和订单退款聚合状态均已完成更新。
+ */
+ SUCCESS("50", "成功");
+
+ /**
+ * 数据库和接口使用的稳定状态编码。
+ */
+ private final String status;
+
+ /**
+ * 管理端展示名称。
+ */
+ private final String description;
+
+ /**
+ * 构造自动退款任务状态协议项。
+ *
+ * @param status 数据库和接口使用的稳定状态编码
+ * @param description 管理端展示名称
+ */
+ HardAutoRefundTaskStatusEnum(String status, String description) {
+ this.status = status;
+ this.description = description;
+ }
+
+ /**
+ * 按持久化编码解析任务状态,未知值返回 {@code null}。
+ *
+ * @param status 数据库存储编码
+ * @return 匹配状态;未知时返回 {@code null}
+ */
+ public static HardAutoRefundTaskStatusEnum valueOfStatus(String status) {
+ return Arrays.stream(values()).filter(item -> item.status.equals(status)).findFirst().orElse(null);
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java
new file mode 100644
index 000000000..31e6da140
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java
@@ -0,0 +1,53 @@
+package com.bsd.pjcloud.hard.api.constant.refund;
+
+import lombok.Getter;
+
+/**
+ * 自动退款尝试触发来源。编码持久化到尝试日志,属于管理端审计协议。
+ */
+@Getter
+public enum HardAutoRefundTriggerTypeEnum {
+
+ /**
+ * 售后申请本地事务提交后的首次同步执行。
+ */
+ INITIAL("10", "首次同步执行", true),
+
+ /**
+ * 每 30 秒调度器触发的到期自动重试。
+ */
+ SCHEDULED("20", "定时重试", true),
+
+ /**
+ * 管理端技术人员对耗尽任务执行的一次额外尝试。
+ */
+ MANUAL("30", "人工技术重试", false);
+
+ /**
+ * 数据库存储的稳定触发编码。
+ */
+ private final String type;
+
+ /**
+ * 管理端展示名称。
+ */
+ private final String description;
+
+ /**
+ * 是否计入八次自动尝试额度;人工技术重试只增加总尝试次数。
+ */
+ private final boolean autoAttempt;
+
+ /**
+ * 构造尝试触发来源协议项。
+ *
+ * @param type 数据库存储的稳定触发编码
+ * @param description 管理端展示名称
+ * @param autoAttempt 是否消耗八次自动尝试额度
+ */
+ HardAutoRefundTriggerTypeEnum(String type, String description, boolean autoAttempt) {
+ this.type = type;
+ this.description = description;
+ this.autoAttempt = autoAttempt;
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAutoRefundTaskPageDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAutoRefundTaskPageDto.java
new file mode 100644
index 000000000..a4594a3ab
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAutoRefundTaskPageDto.java
@@ -0,0 +1,57 @@
+package com.bsd.pjcloud.hard.api.dto.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 自动退款任务管理分页筛选条件。
+ */
+@Data
+@Schema(description = "自动退款任务分页筛选")
+public class HardAutoRefundTaskPageDto {
+
+ /**
+ * 精确任务主键,管理端通常不传。
+ */
+ @Schema(description = "任务主键")
+ private Long id;
+
+ /**
+ * 精确售后单 ID。
+ */
+ @Schema(description = "售后单ID")
+ private Long afterSaleId;
+
+ /**
+ * 精确原订单 ID。
+ */
+ @Schema(description = "原订单ID")
+ private Long orderId;
+
+ /**
+ * 精确商户 ID;必须同时受服务端数据范围约束。
+ */
+ @Schema(description = "商户ID")
+ private Long mchId;
+
+ /**
+ * 任务状态编码。
+ */
+ @Schema(description = "任务状态")
+ private String status;
+
+ /**
+ * 任务创建时间闭区间,数组长度应为 2。
+ */
+ @Schema(description = "创建时间范围")
+ private LocalDateTime[] createTimeList;
+
+ /**
+ * 当前管理用户可访问的商户 ID 列表,由 Controller 根据数据范围注入,不接受客户端放宽。
+ */
+ @Schema(hidden = true)
+ private List mchIds;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateDailyMenuReserveOrdersDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateDailyMenuReserveOrdersDto.java
index 50f7bebd8..42579019d 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateDailyMenuReserveOrdersDto.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateDailyMenuReserveOrdersDto.java
@@ -14,7 +14,7 @@ import java.util.List;
* App 每日菜谱多日期、多餐段批量订餐请求。
*
* 一个分组对应一个父购物车和一个预约日期餐段,服务端会为每个分组下的每位实际用餐人
- * 创建独立订单,并统一从当前付款人选择的钱包扣款。
+ * 创建独立订单,并从各实际用餐人本人已经开通的 EAT 饭卡扣款。
*/
@Data
public class HardAppCreateDailyMenuReserveOrdersDto {
@@ -34,9 +34,9 @@ public class HardAppCreateDailyMenuReserveOrdersDto {
private String payType;
/**
- * 付款人本次选择的钱包类型 ID,服务端会按当前登录用户再次校验钱包归属和余额。
+ * 旧客户端兼容字段。服务端不再使用该值选择钱包,实际扣款钱包固定按每位用餐人的 EAT 饭卡解析;
+ * 新客户端可以不传,旧客户端继续传入也不会改变结算目标。
*/
- @NotNull(message = "钱包类型不能为空")
private Long walletTypeId;
/**
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateReserveOrdersDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateReserveOrdersDto.java
index 27b7d0666..df094055b 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateReserveOrdersDto.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateReserveOrdersDto.java
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.hard.api.dto.order.app;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
import lombok.Data;
import java.time.LocalDate;
@@ -25,6 +26,12 @@ public class HardAppCreateReserveOrdersDto {
@NotEmpty(message = "用餐人不能为空")
private List dinerUserIds;
+ /**
+ * App 支付平台类型。新客户端固定提交钱包支付编码 30;旧客户端未传时按 30 兼容处理,其他编码拒绝进入饭卡直扣。
+ */
+ @Pattern(regexp = "30", message = "批量订餐暂仅支持钱包支付")
+ private String payType;
+
private Long apptId;
private Long apptRuleId;
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppRefundApplyDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppRefundApplyDto.java
index 9116c82fb..bb6939abb 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppRefundApplyDto.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppRefundApplyDto.java
@@ -42,7 +42,7 @@ public class HardAppRefundApplyDto {
/**
* 用户填写的问题描述,便于商户审核本次所选商品。
*/
- @NotBlank(message = "相关描述不能为空")
+ // @NotBlank(message = "相关描述不能为空")
@Schema(description = "相关描述")
private String applyDescription;
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundAttemptLogEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundAttemptLogEntity.java
new file mode 100644
index 000000000..3d8d69811
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundAttemptLogEntity.java
@@ -0,0 +1,141 @@
+package com.bsd.pjcloud.hard.api.entity.order;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.extension.activerecord.Model;
+import com.bsd.pjcloud.common.core.util.TenantTable;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 自动退款单次尝试只读日志。每个任务的 attempt_no 严格递增且由唯一索引防止重复写入。
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@TenantTable
+@TableName("hard_auto_refund_attempt_log")
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "自动退款尝试日志")
+public class HardAutoRefundAttemptLogEntity extends Model {
+
+ /**
+ * 日志主键,使用 MyBatis-Plus 雪花 ID。
+ */
+ @TableId(type = IdType.ASSIGN_ID)
+ @Schema(description = "日志主键")
+ private Long id;
+
+ /**
+ * 所属自动退款任务 ID。
+ */
+ @Schema(description = "任务ID")
+ private Long taskId;
+
+ /**
+ * 所属售后单 ID,便于不经过任务表直接查询完整尝试历史。
+ */
+ @Schema(description = "售后单ID")
+ private Long afterSaleId;
+
+ /**
+ * 任务内递增尝试序号,包含首次开关关闭跳过记录。
+ */
+ @Schema(description = "尝试序号")
+ private Integer attemptNo;
+
+ /**
+ * 触发来源编码,对应 HardAutoRefundTriggerTypeEnum。
+ */
+ @Schema(description = "触发来源")
+ private String triggerType;
+
+ /**
+ * 尝试结果编码,对应 HardAutoRefundAttemptResultEnum。
+ */
+ @Schema(description = "尝试结果")
+ private String resultStatus;
+
+ /**
+ * 稳定钱包退款业务号,格式为 hard_{orderId}_{afterSaleId}。
+ */
+ @Schema(description = "钱包退款业务号")
+ private String refundCode;
+
+ /**
+ * 人工技术重试的操作用户 ID;自动触发时为空。
+ */
+ @Schema(description = "操作用户ID")
+ private Long operatorUserId;
+
+ /**
+ * 本次尝试开始时间,使用服务器默认时区。
+ */
+ @Schema(description = "开始时间")
+ private LocalDateTime startedTime;
+
+ /**
+ * 本次尝试完成时间;执行中临时记录允许为空。
+ */
+ @Schema(description = "完成时间")
+ private LocalDateTime finishedTime;
+
+ /**
+ * 失败诊断信息,最多 2000 字符;成功和开关跳过时为空。
+ */
+ @Schema(description = "错误信息")
+ private String errorMessage;
+
+ /**
+ * 创建人账号,由公共字段填充器写入。
+ */
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "创建人")
+ private String createBy;
+
+ /**
+ * 日志创建时间,使用服务器和数据库连接默认时区。
+ */
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "创建时间")
+ private LocalDateTime createTime;
+
+ /**
+ * 最后修改人账号;日志仅在同一尝试事务内从执行中更新为最终结果。
+ */
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ @Schema(description = "修改人")
+ private String updateBy;
+
+ /**
+ * 最后修改时间;日志最终完成后不再允许业务修改。
+ */
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ @Schema(description = "修改时间")
+ private LocalDateTime updateTime;
+
+ /**
+ * 逻辑删除标记;尝试日志不提供修改或删除接口。
+ */
+ @TableLogic
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "逻辑删除标记")
+ private String delFlag;
+
+ /**
+ * 日志所属租户 ID,用于查询隔离。
+ */
+ @Schema(description = "租户ID")
+ private Long tenantId;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java
new file mode 100644
index 000000000..21024beb6
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java
@@ -0,0 +1,159 @@
+package com.bsd.pjcloud.hard.api.entity.order;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.extension.activerecord.Model;
+import com.bsd.pjcloud.common.core.util.TenantTable;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * Hardware 自动退款持久化任务。一个售后单最多创建一条任务,失败后由该记录持续驱动恢复。
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@TenantTable
+@TableName("hard_auto_refund_task")
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "自动退款任务")
+public class HardAutoRefundTaskEntity extends Model {
+
+ /**
+ * 任务主键,使用 MyBatis-Plus 雪花 ID。
+ */
+ @TableId(type = IdType.ASSIGN_ID)
+ @Schema(description = "任务主键")
+ private Long id;
+
+ /**
+ * 售后单 ID,是任务唯一业务键和钱包退款幂等键。
+ */
+ @Schema(description = "售后单ID")
+ private Long afterSaleId;
+
+ /**
+ * 原 Hardware 订单 ID,用于订单级分布式锁和退款状态聚合。
+ */
+ @Schema(description = "原订单ID")
+ private Long orderId;
+
+ /**
+ * 订单所属商户 ID,用于管理端数据范围和配置实时复核。
+ */
+ @Schema(description = "商户ID")
+ private Long mchId;
+
+ /**
+ * 下单时快照引用的 Wallet 自动退款配置主键。
+ */
+ @Schema(description = "配置主键")
+ private Long configId;
+
+ /**
+ * 下单时快照的规则名称,重试期间不跟随配置规则切换而改变。
+ */
+ @Schema(description = "自动退款规则快照")
+ private String ruleType;
+
+ /**
+ * 用户申请免审退款的闭区间截止时间;任务创建后跨过该时间仍继续重试。
+ */
+ @Schema(description = "免审资格截止时间")
+ private LocalDateTime eligibilityDeadline;
+
+ /**
+ * 当前任务状态编码,对应 HardAutoRefundTaskStatusEnum。
+ */
+ @Schema(description = "任务状态")
+ private String status;
+
+ /**
+ * 已消耗的自动尝试次数,包含首次同步执行,最大为 8;开关跳过和人工重试不增加。
+ */
+ @Schema(description = "自动尝试次数")
+ private Integer autoAttemptCount;
+
+ /**
+ * 全部审计尝试序号计数,包含自动、人工和首次开关关闭跳过,用于生成唯一 attempt_no。
+ */
+ @Schema(description = "总尝试次数")
+ private Integer totalAttemptCount;
+
+ /**
+ * 等待重试任务的下一次到期时间;暂停、耗尽和成功状态允许为空。
+ */
+ @Schema(description = "下次重试时间")
+ private LocalDateTime nextRetryTime;
+
+ /**
+ * 最近一次实际尝试或开关跳过的开始时间。
+ */
+ @Schema(description = "末次尝试时间")
+ private LocalDateTime lastAttemptTime;
+
+ /**
+ * 钱包到账及 Hardware 状态全部提交成功的时间。
+ */
+ @Schema(description = "成功时间")
+ private LocalDateTime successTime;
+
+ /**
+ * 最近一次失败的诊断信息,最多保存 2000 字符,不包含凭证或敏感资金数据。
+ */
+ @Schema(description = "末次错误")
+ private String lastError;
+
+ /**
+ * 创建人账号,由公共字段填充器写入。
+ */
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "创建人")
+ private String createBy;
+
+ /**
+ * 任务创建时间,使用服务器和数据库连接默认时区。
+ */
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "创建时间")
+ private LocalDateTime createTime;
+
+ /**
+ * 最后修改人账号,由公共字段填充器维护。
+ */
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ @Schema(description = "修改人")
+ private String updateBy;
+
+ /**
+ * 最后状态更新时间,使用服务器和数据库连接默认时区。
+ */
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ @Schema(description = "修改时间")
+ private LocalDateTime updateTime;
+
+ /**
+ * 逻辑删除标记;任务管理不提供删除接口。
+ */
+ @TableLogic
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "逻辑删除标记")
+ private String delFlag;
+
+ /**
+ * 任务所属租户 ID,调度器会按该值恢复租户上下文后执行。
+ */
+ @Schema(description = "租户ID")
+ private Long tenantId;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java
index 2b48f4974..b866cf6cb 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java
@@ -141,6 +141,24 @@ public class HardOrderEntity extends Model {
@Schema(description = "预计使用日期,食堂预约订单取预约目标日期,其他订单取下单当天,精确到天")
private LocalDate apptUseDate;
+ /**
+ * 下单时命中的商户自动退款配置主键快照;为空表示订单不具备免审资格。
+ */
+ @Schema(description = "自动退款配置主键快照")
+ private Long autoRefundConfigId;
+
+ /**
+ * 下单时保存的自动退款规则名称;退款申请时不重新按新规则计算截止时间。
+ */
+ @Schema(description = "自动退款规则快照")
+ private String autoRefundRuleType;
+
+ /**
+ * 免审退款申请截止时间,使用服务器默认时区并按 {@code now <= deadline} 判断。
+ */
+ @Schema(description = "自动退款申请截止时间")
+ private LocalDateTime autoRefundDeadline;
+
/**
* 订单设备类型
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java
index 21324cdff..cb1fec68d 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java
@@ -10,6 +10,11 @@ import java.time.LocalDateTime;
@Data
public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserInterface, BaseDeptInterface, BaseOrderInterface {
+ /**
+ * 自动退款任务状态;为空表示当前售后仍走人工审核,非空时前端不得展示审核或取消操作。
+ */
+ private String autoRefundTaskStatus;
+
/**
* 用户相关
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java
new file mode 100644
index 000000000..bb2c31066
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java
@@ -0,0 +1,45 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 自动退款任务管理响应,补充售后金额、用户和商户展示信息。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "自动退款任务管理响应")
+public class HardAutoRefundTaskVo extends HardAutoRefundTaskEntity {
+
+ /**
+ * 售后申请用户 ID。
+ */
+ @Schema(description = "用户ID")
+ private Long userId;
+
+ /**
+ * 售后申请用户展示名称,远程用户服务异常时为空但不影响任务查询。
+ */
+ @Schema(description = "用户名称")
+ private String userName;
+
+ /**
+ * 商户展示名称,远程商户服务异常时为空但保留商户 ID。
+ */
+ @Schema(description = "商户名称")
+ private String mchName;
+
+ /**
+ * 售后单服务端核定退款金额,单位为分。
+ */
+ @Schema(description = "退款金额,单位为分")
+ private Integer refundPrice;
+
+ /**
+ * 售后单当前状态,用于区分处理中和资金已完成。
+ */
+ @Schema(description = "售后状态")
+ private String afterSaleStatus;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppAutoRefundTaskVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppAutoRefundTaskVo.java
new file mode 100644
index 000000000..8b6ab8823
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppAutoRefundTaskVo.java
@@ -0,0 +1,44 @@
+package com.bsd.pjcloud.hard.api.vo.order.app;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * App 自动退款处理状态,只暴露用户理解进度所需字段,不返回内部错误详情。
+ */
+@Data
+@Schema(description = "App自动退款处理状态")
+public class HardAppAutoRefundTaskVo {
+
+ /**
+ * 售后单 ID,供退款进度页确认当前查询对象。
+ */
+ @Schema(description = "售后单ID")
+ private Long afterSaleId;
+
+ /**
+ * 自动退款任务状态编码;不存在自动任务时接口返回空数据。
+ */
+ @Schema(description = "自动退款任务状态")
+ private String status;
+
+ /**
+ * 自动尝试次数,开关暂停和人工技术重试不计入该值。
+ */
+ @Schema(description = "自动尝试次数")
+ private Integer autoAttemptCount;
+
+ /**
+ * 等待重试状态下的下一次计划时间,其他状态允许为空。
+ */
+ @Schema(description = "下次重试时间")
+ private LocalDateTime nextRetryTime;
+
+ /**
+ * 自动退款全部完成时间;处理中和暂停状态为空。
+ */
+ @Schema(description = "完成时间")
+ private LocalDateTime successTime;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppRefundApplyVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppRefundApplyVo.java
index 2699614f6..bf06ee191 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppRefundApplyVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppRefundApplyVo.java
@@ -55,6 +55,12 @@ public class HardAppRefundApplyVo {
@Schema(description = "是否可以继续在线申请")
private Boolean canApply;
+ /**
+ * 是否退回实际用餐人的饭卡。仅食堂预约钱包订单为 {@code true},其他订单仍按原支付渠道原路退款。
+ */
+ @Schema(description = "是否退回实际用餐人的饭卡")
+ private Boolean refundToDinerWallet;
+
/**
* 最新售后单 ID;没有历史申请时为空。
*/
@@ -85,6 +91,36 @@ public class HardAppRefundApplyVo {
@Schema(description = "最新状态更新时间")
private LocalDateTime latestUpdateTime;
+ /**
+ * 当前订单是否可在本次提交后进入免审自动退款,按订单快照、闭区间截止和实时开关共同判断。
+ */
+ @Schema(description = "是否具备自动退款资格")
+ private Boolean autoRefundEligible;
+
+ /**
+ * 下单时快照的自动退款规则名称;历史无快照订单为空。
+ */
+ @Schema(description = "自动退款规则")
+ private String autoRefundRuleType;
+
+ /**
+ * 下单时快照的免审申请截止时间;历史无快照订单为空。
+ */
+ @Schema(description = "自动退款截止时间")
+ private LocalDateTime autoRefundDeadline;
+
+ /**
+ * 最新售后单关联的自动退款任务状态;人工审核售后为空。
+ */
+ @Schema(description = "自动退款任务状态")
+ private String autoRefundTaskStatus;
+
+ /**
+ * 最新售后单是否允许用户取消;存在自动退款任务时始终为 {@code false}。
+ */
+ @Schema(description = "最新售后单是否允许取消")
+ private Boolean latestAfterSaleCancelable;
+
/**
* 按原订单商品 ID 聚合后的退款商品列表。
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java
new file mode 100644
index 000000000..a66b061ae
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java
@@ -0,0 +1,155 @@
+package com.bsd.pjcloud.hard.controller.order;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
+import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.idempotent.annotation.Idempotent;
+import com.bsd.pjcloud.common.security.util.SecurityUtils;
+import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto;
+import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity;
+import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
+import com.bsd.pjcloud.hard.api.vo.order.HardAutoRefundTaskVo;
+import com.bsd.pjcloud.hard.api.vo.order.app.HardAppAutoRefundTaskVo;
+import com.bsd.pjcloud.hard.service.HardAfterSaleService;
+import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService;
+import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
+import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
+import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springdoc.core.annotations.ParameterObject;
+import org.springframework.http.HttpHeaders;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 自动退款任务管理和 App 只读进度接口。
+ */
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/hard-auto-refund-task")
+@Tag(description = "hard-auto-refund-task", name = "自动退款任务")
+@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
+public class HardAutoRefundTaskController {
+
+ /**
+ * 自动退款任务状态机服务。
+ */
+ private final HardAutoRefundTaskService taskService;
+
+ /**
+ * 售后服务,用于 App 查询任务前复用订单查看授权。
+ */
+ private final HardAfterSaleService hardAfterSaleService;
+
+ /**
+ * 商户服务,用于管理接口按当前用户数据范围限制任务。
+ */
+ private final RemoteMchService remoteMchService;
+
+ /**
+ * 按当前用户商户数据范围分页查询自动退款任务。
+ *
+ * @param page 分页参数
+ * @param dto 筛选条件
+ * @return 任务分页
+ */
+ @GetMapping("/page")
+ @Operation(summary = "分页查询自动退款任务")
+ @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_view')")
+ public R> page(@ParameterObject Page page,
+ @ParameterObject HardAutoRefundTaskPageDto dto) {
+ List scopedMchIds = listScopedMerchantIds();
+ if (scopedMchIds.isEmpty()) {
+ Page empty = new Page<>(page.getCurrent(), page.getSize(), 0L);
+ empty.setRecords(List.of());
+ return R.ok(empty);
+ }
+ dto.setMchIds(scopedMchIds);
+ return R.ok(taskService.pageTasks(page, dto));
+ }
+
+ /**
+ * 查询指定任务全部只读尝试日志。
+ *
+ * @param id 任务主键
+ * @return 按尝试序号升序的日志列表
+ */
+ @GetMapping("/{id}/attempts")
+ @Operation(summary = "查询自动退款尝试日志")
+ @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_view')")
+ public R> attempts(@PathVariable Long id) {
+ validateTaskScope(id);
+ return R.ok(taskService.listAttempts(id));
+ }
+
+ /**
+ * 对重试耗尽任务额外执行一次技术重试。
+ *
+ * @param id 任务主键
+ * @return 操作成功标记;资金失败仍会返回成功受理并保持耗尽状态
+ */
+ @PostMapping("/{id}/retry")
+ @Operation(summary = "人工重新触发自动退款")
+ @Idempotent(expireTime = 5, info = "正在重新触发,请勿重复操作")
+ @PreAuthorize("@pms.hasPermission('hard_autoRefundTask_retry')")
+ public R retry(@PathVariable Long id) {
+ validateTaskScope(id);
+ taskService.manualRetry(id, SecurityUtils.getUser().getId());
+ return R.ok(true);
+ }
+
+ /**
+ * App 按售后单查询自动退款进度,人工审核售后返回空数据。
+ *
+ * @param afterSaleId 售后单 ID
+ * @return 用户可见的任务状态
+ */
+ @ApiCryptoAes
+ @GetMapping("/app/status")
+ @Operation(summary = "App查询自动退款进度")
+ public R appStatus(@RequestParam Long afterSaleId) {
+ hardAfterSaleService.validateAppAfterSaleAccess(afterSaleId, SecurityUtils.getUser().getId());
+ return R.ok(taskService.getAppTaskStatus(afterSaleId));
+ }
+
+ /**
+ * 校验任务商户属于当前管理用户数据范围。
+ *
+ * @param taskId 任务主键
+ */
+ private void validateTaskScope(Long taskId) {
+ HardAutoRefundTaskEntity task = taskService.getById(taskId);
+ if (task == null) {
+ throw new RuntimeException("自动退款任务不存在");
+ }
+ if (listScopedMerchantIds().stream().noneMatch(id -> Objects.equals(id, task.getMchId()))) {
+ throw new RuntimeException("无权访问当前自动退款任务");
+ }
+ }
+
+ /**
+ * 查询当前管理用户可访问的食堂商户 ID;远程异常拒绝请求,不能降级为全量任务。
+ *
+ * @return 当前用户可访问商户 ID 列表
+ */
+ private List listScopedMerchantIds() {
+ MchManageDto query = MchManageDto.builder().typesList(List.of(MerchantTypeEnum.CANTEEN.getType())).build();
+ R> response = remoteMchService.scopeList(query);
+ if (response == null || !response.isOk() || response.getData() == null) {
+ throw new RuntimeException("查询商户数据范围失败");
+ }
+ return response.getData().stream().filter(Objects::nonNull).map(MchManageVo::getId).filter(Objects::nonNull).distinct().toList();
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
index 5fe194dac..d37804475 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
@@ -332,9 +332,9 @@ public class HardOrderController {
}
/**
- * 当前用户使用自己的餐饮钱包,为本人和有效被监护人分别创建独立预约订单。
+ * 当前用户为本人和有效被监护人代订,每笔订单从实际用餐人本人的 EAT 饭卡扣款。
*/
- @Operation(summary = "app批量创建食堂预定订单", description = "每位用餐人创建一笔独立订单,统一从当前用户钱包扣款")
+ @Operation(summary = "app批量创建食堂预定订单", description = "每位用餐人创建一笔独立订单,并分别从本人饭卡扣款")
@ApiCryptoAes
@PostMapping("/app/create-reserve-orders")
@Idempotent(expireTime = 30,
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJob.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJob.java
new file mode 100644
index 000000000..2a6ae9314
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJob.java
@@ -0,0 +1,57 @@
+package com.bsd.pjcloud.hard.job;
+
+import com.bsd.pjcloud.common.data.tenant.TenantBroker;
+import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
+import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
+import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * 自动退款任务补偿调度器。多实例允许同时扫描,订单 Redis 锁和任务行锁负责最终串行。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HardAutoRefundTaskJob {
+
+ /**
+ * 调度器触发间隔,单位毫秒,固定为 30 秒。
+ */
+ private static final long SCAN_INTERVAL_MILLIS = 30_000L;
+
+ /**
+ * 单轮跨租户最多扫描任务数量,避免配置和钱包服务突发压力。
+ */
+ private static final int SCAN_BATCH_SIZE = 100;
+
+ /**
+ * 自动退款任务服务。
+ */
+ private final HardAutoRefundTaskService taskService;
+
+ /**
+ * 每 30 秒扫描到期或暂停任务,并按任务持久化租户恢复 Feign 和数据库上下文。
+ */
+ @Scheduled(fixedDelay = SCAN_INTERVAL_MILLIS, initialDelay = SCAN_INTERVAL_MILLIS)
+ public void retryDueTasks() {
+ List tasks = taskService.listScanCandidates(SCAN_BATCH_SIZE);
+ for (HardAutoRefundTaskEntity task : tasks) {
+ if (task.getTenantId() == null) {
+ log.warn("自动退款任务缺少租户ID,taskId:{}", task.getId());
+ continue;
+ }
+ try {
+ TenantBroker.runAs(task.getTenantId(), tenantId ->
+ taskService.execute(task.getId(), HardAutoRefundTriggerTypeEnum.SCHEDULED, null));
+ } catch (Exception exception) {
+ log.warn("自动退款调度执行异常,taskId:{},tenantId:{},exceptionType:{}", task.getId(), task.getTenantId(),
+ exception.getClass().getSimpleName());
+ }
+ }
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundAttemptLogMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundAttemptLogMapper.java
new file mode 100644
index 000000000..20b12888e
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundAttemptLogMapper.java
@@ -0,0 +1,12 @@
+package com.bsd.pjcloud.hard.mapper.order;
+
+import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 自动退款尝试日志 Mapper。业务仅新增并在同一尝试事务内完成结果更新,不提供删除能力。
+ */
+@Mapper
+public interface HardAutoRefundAttemptLogMapper extends PjcloudBaseMapper {
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java
new file mode 100644
index 000000000..41ea25c22
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java
@@ -0,0 +1,51 @@
+package com.bsd.pjcloud.hard.mapper.order;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto;
+import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 自动退款任务 Mapper,任务执行通过主键行锁串行化多实例竞争。
+ */
+@Mapper
+public interface HardAutoRefundTaskMapper extends PjcloudBaseMapper {
+
+ /**
+ * 按主键锁定任务行,供状态复查、尝试计数和最终状态更新使用。
+ *
+ * @param id 任务主键
+ * @return 已锁定任务;不存在或已逻辑删除时返回 {@code null}
+ */
+ default HardAutoRefundTaskEntity selectByIdForUpdate(Long id) {
+ return selectOne(new LambdaQueryWrapper()
+ .eq(HardAutoRefundTaskEntity::getId, id)
+ .last("FOR UPDATE"));
+ }
+
+ /**
+ * 按管理筛选和服务端商户数据范围分页查询任务。
+ *
+ * @param page 分页参数
+ * @param dto 筛选条件,mchIds 由服务端注入
+ * @return 自动退款任务分页
+ */
+ default Page pageTasks(Page page, HardAutoRefundTaskPageDto dto) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
+ .eq(dto.getId() != null, HardAutoRefundTaskEntity::getId, dto.getId())
+ .eq(dto.getAfterSaleId() != null, HardAutoRefundTaskEntity::getAfterSaleId, dto.getAfterSaleId())
+ .eq(dto.getOrderId() != null, HardAutoRefundTaskEntity::getOrderId, dto.getOrderId())
+ .eq(dto.getMchId() != null, HardAutoRefundTaskEntity::getMchId, dto.getMchId())
+ .eq(dto.getStatus() != null && !dto.getStatus().isBlank(), HardAutoRefundTaskEntity::getStatus, dto.getStatus())
+ .in(dto.getMchIds() != null && !dto.getMchIds().isEmpty(), HardAutoRefundTaskEntity::getMchId, dto.getMchIds())
+ .between(dto.getCreateTimeList() != null && dto.getCreateTimeList().length == 2,
+ HardAutoRefundTaskEntity::getCreateTime,
+ dto.getCreateTimeList() == null ? null : dto.getCreateTimeList()[0],
+ dto.getCreateTimeList() == null ? null : dto.getCreateTimeList()[1])
+ .orderByDesc(HardAutoRefundTaskEntity::getCreateTime)
+ .orderByDesc(HardAutoRefundTaskEntity::getId);
+ return selectPage(page, wrapper);
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java
index 1c4a02bc0..50431a38a 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java
@@ -64,6 +64,14 @@ public interface HardAfterSaleService extends IService {
void refund(Long afterSaleId, String prefixStr);
+ /**
+ * 由自动退款任务在已持有订单锁的事务中完成钱包退款、售后状态、库存和订单聚合更新。
+ *
+ * @param afterSaleId 自动退款任务绑定的售后单 ID
+ * @param autoRefundConfigId 下单快照中的自动退款配置 ID,Wallet 据此执行最终开关行锁校验
+ */
+ void completeAutoRefund(Long afterSaleId, Long autoRefundConfigId);
+
/**
* 同意售后
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java
index 254b935ce..4d5f3bbc0 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java
@@ -5,6 +5,7 @@ import com.bsd.pjcloud.hard.api.vo.HardApptDateOptionsVo;
import com.bsd.pjcloud.hard.api.vo.HardApptTimeCascadeVo;
import java.time.LocalDate;
+import java.time.LocalDateTime;
import java.util.List;
/**
@@ -30,6 +31,16 @@ public interface HardApptReservationService {
ReservationValidation validateReservation(Long mchId, Long apptId, Long apptRuleId, String hourType,
LocalDate apptDate, String legacyApptTimeType);
- record ReservationValidation(HardApptEntity appt, String hourType, LocalDate apptDate, Long apptRuleId) {
+ /**
+ * 服务端校验通过的预约上下文,周期批次截止时间可直接作为自动退款订单快照,后续不再重算规则。
+ *
+ * @param appt 动态预约配置;周期模式为空
+ * @param hourType 标准化餐段编码
+ * @param apptDate 实际用餐日期
+ * @param apptRuleId 周期预约规则 ID;动态模式为空
+ * @param eligibilityDeadline 当前已校验预约窗口的截止时间,使用服务器现有预约时间口径
+ */
+ record ReservationValidation(HardApptEntity appt, String hourType, LocalDate apptDate, Long apptRuleId,
+ LocalDateTime eligibilityDeadline) {
}
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java
new file mode 100644
index 000000000..c88ac2c1b
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java
@@ -0,0 +1,110 @@
+package com.bsd.pjcloud.hard.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
+import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto;
+import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
+import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity;
+import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
+import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
+import com.bsd.pjcloud.hard.api.vo.order.HardAutoRefundTaskVo;
+import com.bsd.pjcloud.hard.api.vo.order.app.HardAppAutoRefundTaskVo;
+
+import java.util.List;
+
+/**
+ * 自动退款任务状态机服务。
+ */
+public interface HardAutoRefundTaskService extends IService {
+
+ /**
+ * 在售后创建事务中根据订单快照、截止时间和当前配置尝试创建唯一任务。
+ *
+ * @param order 已锁定的原订单
+ * @param afterSale 新建售后单
+ * @return 已创建任务;不符合免审条件或配置查询失败时返回 {@code null}
+ */
+ HardAutoRefundTaskEntity tryCreateTask(HardOrderEntity order, HardAfterSaleEntity afterSale);
+
+ /**
+ * 注册事务提交后的同步首次执行;回调内部吞掉资金异常并持久化重试状态。
+ *
+ * @param taskId 新建任务主键
+ */
+ void executeFirstAfterCommit(Long taskId);
+
+ /**
+ * 在订单 Redis 锁和任务行锁保护下执行一次自动或人工尝试。
+ *
+ * @param taskId 任务主键
+ * @param triggerType 触发来源
+ * @param operatorUserId 人工重试用户 ID;自动触发为空
+ */
+ void execute(Long taskId, HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId);
+
+ /**
+ * 分页查询管理端任务并补充用户、商户和售后金额。
+ *
+ * @param page 分页参数
+ * @param dto 服务端已注入商户数据范围的筛选条件
+ * @return 任务分页
+ */
+ Page pageTasks(Page page, HardAutoRefundTaskPageDto dto);
+
+ /**
+ * 查询任务的只读尝试日志,按尝试序号升序返回。
+ *
+ * @param taskId 任务主键
+ * @return 尝试日志列表
+ */
+ List listAttempts(Long taskId);
+
+ /**
+ * 对重试耗尽任务执行一次额外技术重试,失败后仍保持耗尽状态。
+ *
+ * @param taskId 任务主键
+ * @param operatorUserId 当前管理用户 ID
+ */
+ void manualRetry(Long taskId, Long operatorUserId);
+
+ /**
+ * 按售后单查询自动退款任务。
+ *
+ * @param afterSaleId 售后单 ID
+ * @return 自动退款任务;人工售后返回 {@code null}
+ */
+ HardAutoRefundTaskEntity getByAfterSaleId(Long afterSaleId);
+
+ /**
+ * 判断售后单是否已由自动任务占用,存在任意状态任务都禁止取消和商户审核。
+ *
+ * @param afterSaleId 售后单 ID
+ * @return 存在任务时返回 {@code true}
+ */
+ boolean existsByAfterSaleId(Long afterSaleId);
+
+ /**
+ * 判断订单当前是否仍满足快照、闭区间截止和实时开关条件;配置异常按不满足处理。
+ *
+ * @param order 原订单
+ * @return 当前提交是否可进入自动退款
+ */
+ boolean isEligibleNow(HardOrderEntity order);
+
+ /**
+ * 返回 App 所需的只读任务进度,不暴露内部错误详情。
+ *
+ * @param afterSaleId 售后单 ID
+ * @return App 任务状态;人工售后返回 {@code null}
+ */
+ HardAppAutoRefundTaskVo getAppTaskStatus(Long afterSaleId);
+
+ /**
+ * 调度器无租户上下文时跨租户选取本轮最多指定数量的可执行或暂停任务。
+ *
+ * @param limit 最大扫描数量
+ * @return 待复查任务列表
+ */
+ List listScanCandidates(int limit);
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java
index 8ecf58746..3c1e3be61 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java
@@ -357,7 +357,7 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
if (!availability.available()) {
throw new RuntimeException(availability.unavailableReason());
}
- return new ReservationValidation(appt, appt.getHourType(), targetDate, null);
+ return new ReservationValidation(appt, appt.getHourType(), targetDate, null, availability.endDateTime());
}
private ReservationValidation validateCycleReservation(Long mchId, Long apptRuleId, String hourType,
@@ -377,7 +377,7 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
if (!match.availability().available()) {
throw new RuntimeException(match.availability().unavailableReason());
}
- return new ReservationValidation(null, hourType, apptDate, selected.getId());
+ return new ReservationValidation(null, hourType, apptDate, selected.getId(), match.availability().endDateTime());
}
private CycleMatch matchCycleRule(HardApptCycleRuleVo rule, String hourType,
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java
index a680a5128..bf69eb2a7 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java
@@ -50,7 +50,9 @@ import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMicropayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdRemoterRefundDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdMiniWechatRefundDto;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeRefundDto;
+import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService;
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletRechargeService;
+import com.bsd.pjcloud.wallet.api.vo.WalletManageVo;
import com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -99,10 +101,15 @@ public class HardAfterSaleServiceImpl extends ServiceImpl orderList = hardOrderMapper.list(hardOrderDto);
HardUtil.convertOrderList(list.getRecords(), CollectionUtils.convertMap(orderList, HardOrderEntity::getId));
}
+ fillAutoRefundTaskStatus(list.getRecords());
}
return list;
}
@@ -159,6 +167,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl 0 || afterSales.stream()
.anyMatch(item -> ObjectUtil.equals(item.getStatus(), HardSaleStatusEnum.APPLY.getStatus())));
HardAfterSaleEntity latest = afterSales.stream().max(Comparator.comparing(HardAfterSaleEntity::getId)).orElse(null);
+ HardAutoRefundTaskEntity latestTask = latest == null ? null : hardAutoRefundTaskService.getByAfterSaleId(latest.getId());
if (latest != null) {
detail.setLatestAfterSaleId(latest.getId());
detail.setLatestAfterSaleStatus(latest.getStatus());
@@ -282,6 +297,13 @@ public class HardAfterSaleServiceImpl extends ServiceImpl products = allocations.values().stream().map(allocation -> {
HardAppRefundProductVo product = new HardAppRefundProductVo();
@@ -306,6 +328,31 @@ public class HardAfterSaleServiceImpl extends ServiceImpl walletResult = walletService.getWalletById(order.getWalletId());
+ WalletManageVo wallet = walletResult == null || !walletResult.isOk() ? null : walletResult.getData();
+ return wallet != null && Objects.equals(wallet.getUserId(), order.getUserId());
+ } catch (Exception exception) {
+ // 查询失败时回退为“原路退款”,避免把改造前绑定代订人钱包的历史订单错误提示为用餐人饭卡。
+ log.warn("|{}|核对订单退款钱包所有人失败,orderId:|{}|,walletId:|{}|", TAG,
+ order.getId(), order.getWalletId(), exception);
+ return false;
+ }
+ }
+
/**
* 售后读取沿用订单查看权限,避免代订订单通过售后接口越权暴露。
*/
@@ -394,6 +441,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl walletRefundResult = walletRechargeService.refund(hardRefundDto);
- if (walletRefundResult.getCode() != 0 || ObjectUtil.isEmpty(walletRefundResult.getData())) {
+ if (walletRefundResult == null || !walletRefundResult.isOk() || ObjectUtil.isEmpty(walletRefundResult.getData())) {
throw new RuntimeException("钱包退款错误");
}
WalletRechargeVo walletRefund = walletRefundResult.getData();
@@ -548,6 +610,55 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId);
+ if (ObjectUtil.isEmpty(refundCars)) {
+ hardOrderService.fastenerInventory(order, YesNoEnum.NO);
+ } else {
+ hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO);
+ }
recomputeOrderRefundState(order.getId());
}
@@ -778,6 +889,35 @@ public class HardAfterSaleServiceImpl extends ServiceImpl records) {
+ List afterSaleIds = records.stream().map(HardAfterSaleVo::getId).filter(Objects::nonNull).distinct().toList();
+ if (afterSaleIds.isEmpty()) {
+ return;
+ }
+ Map taskStatusMap = hardAutoRefundTaskService.list(
+ new LambdaQueryWrapper()
+ .in(HardAutoRefundTaskEntity::getAfterSaleId, afterSaleIds))
+ .stream().collect(Collectors.toMap(HardAutoRefundTaskEntity::getAfterSaleId,
+ HardAutoRefundTaskEntity::getStatus, (first, second) -> first));
+ records.forEach(item -> item.setAutoRefundTaskStatus(taskStatusMap.get(item.getId())));
+ }
+
+ /**
+ * 自动退款任务一经创建即永久占用该售后单,旧 App 和旧管理端也不得取消、同意或拒绝。
+ *
+ * @param afterSaleId 待执行人工操作的售后单 ID
+ */
+ private void ensureNoAutoRefundTask(Long afterSaleId) {
+ if (hardAutoRefundTaskService.existsByAfterSaleId(afterSaleId)) {
+ throw new RuntimeException("自动退款处理中,当前售后单不允许人工取消或审核");
+ }
+ }
+
/**
* 读取订单购物车快照,退款金额和商品展示均不得回查可能已改价或下架的商品主数据。
*
@@ -1191,9 +1331,16 @@ public class HardAfterSaleServiceImpl extends ServiceImpl
+ implements HardAutoRefundTaskService {
+
+ /**
+ * 自动退款日志标签,只记录任务、订单和异常类型,不输出资金凭证或用户敏感信息。
+ */
+ private static final String TAG = "自动退款任务";
+
+ /**
+ * 与售后取消、同意和拒绝共用的订单退款锁键前缀。
+ */
+ private static final String REFUND_ORDER_LOCK_PREFIX = "hard:refund:order:";
+
+ /**
+ * 单次自动退款订单锁最长持有时间,单位毫秒。
+ */
+ private static final long REFUND_ORDER_LOCK_EXPIRE_MILLIS = 60_000L;
+
+ /**
+ * 自动退款获取订单锁的最大等待时间,单位毫秒。
+ */
+ private static final long REFUND_ORDER_LOCK_WAIT_MILLIS = 3_000L;
+
+ /**
+ * 首次同步执行加七次自动重试的最大自动尝试次数。
+ */
+ private static final int MAX_AUTO_ATTEMPTS = 8;
+
+ /**
+ * 第 1 至第 7 次自动失败后的固定重试间隔,单位分钟。
+ */
+ private static final long[] RETRY_DELAY_MINUTES = {1L, 5L, 15L, 30L, 60L, 180L, 360L};
+
+ /**
+ * 配置服务异常后再次复查的等待时间,单位秒,不消耗自动尝试额度。
+ */
+ private static final long CONFIG_RECHECK_DELAY_SECONDS = 30L;
+
+ /**
+ * 任务和尝试日志错误字段允许保存的最大字符数。
+ */
+ private static final int MAX_ERROR_LENGTH = 2000;
+
+ /**
+ * 首期支持的预约订单类型编码。
+ */
+ private static final String SUPPORTED_ORDER_TYPE = "2";
+
+ /**
+ * 尝试日志 Mapper,用于保存每次自动、人工和开关跳过记录。
+ */
+ private final HardAutoRefundAttemptLogMapper attemptLogMapper;
+
+ /**
+ * 售后 Mapper,用于管理分页补充金额和状态。
+ */
+ private final HardAfterSaleMapper afterSaleMapper;
+
+ /**
+ * 售后服务延迟提供器,用于显式打破售后申请创建任务与任务完成售后的循环依赖。
+ */
+ private final ObjectProvider hardAfterSaleServiceProvider;
+
+ /**
+ * Wallet 自动退款配置远程服务,用于受理和每次尝试前复核当前开关。
+ */
+ private final RemoteMchAutoRefundConfigService remoteConfigService;
+
+ /**
+ * Wallet 退款远程服务,用于识别资金已到账但 Hardware 本地事务未提交的一致性恢复场景。
+ */
+ private final RemoteWalletRechargeService remoteWalletRechargeService;
+
+ /**
+ * 商户远程服务,用于管理分页补充商户名称。
+ */
+ private final RemoteMchService remoteMchService;
+
+ /**
+ * 用户远程服务,用于管理分页补充申请用户名称。
+ */
+ private final RemoteUserService remoteUserService;
+
+ /**
+ * 订单级分布式锁模板,与既有售后操作使用相同 Redis 锁。
+ */
+ private final LockTemplate lockTemplate;
+
+ /**
+ * 在售后申请事务内创建任务。配置服务异常、开关关闭或快照不完整时返回空并保留人工审核流程。
+ *
+ * @param order 已锁定的原订单
+ * @param afterSale 新建售后单
+ * @return 已创建任务;不符合免审条件时返回 {@code null}
+ */
+ @Override
+ public HardAutoRefundTaskEntity tryCreateTask(HardOrderEntity order, HardAfterSaleEntity afterSale) {
+ if (!hasValidSnapshot(order) || afterSale == null || afterSale.getId() == null
+ || LocalDateTime.now().isAfter(order.getAutoRefundDeadline())) {
+ return null;
+ }
+ MchAutoRefundConfigVo current;
+ try {
+ current = loadCurrentConfig(order.getMerchantId(), order.getOrderType());
+ } catch (CurrentConfigLookupException exception) {
+ log.warn("|{}|受理时查询配置失败,orderId:{},exceptionType:{}", TAG, order.getId(),
+ exception.getCause() == null ? exception.getClass().getSimpleName() : exception.getCause().getClass().getSimpleName());
+ return null;
+ }
+ if (!matchesSnapshot(order.getAutoRefundConfigId(), order.getAutoRefundRuleType(), current)
+ || !Objects.equals(current.getEnabled(), YesNoEnum.YES.getCode())) {
+ return null;
+ }
+ HardAutoRefundTaskEntity task = HardAutoRefundTaskEntity.builder()
+ .afterSaleId(afterSale.getId())
+ .orderId(order.getId())
+ .mchId(order.getMerchantId())
+ .configId(order.getAutoRefundConfigId())
+ .ruleType(order.getAutoRefundRuleType())
+ .eligibilityDeadline(order.getAutoRefundDeadline())
+ .status(HardAutoRefundTaskStatusEnum.PENDING.getStatus())
+ .autoAttemptCount(0)
+ .totalAttemptCount(0)
+ .nextRetryTime(LocalDateTime.now())
+ .build();
+ if (baseMapper.insert(task) != 1) {
+ throw new RuntimeException("创建自动退款任务失败");
+ }
+ return task;
+ }
+
+ /**
+ * 事务提交成功后在当前请求线程同步执行首次退款,执行失败只更新任务,不改变创建接口返回值。
+ *
+ * @param taskId 新建任务主键
+ */
+ @Override
+ public void executeFirstAfterCommit(Long taskId) {
+ Runnable action = () -> {
+ try {
+ execute(taskId, HardAutoRefundTriggerTypeEnum.INITIAL, null);
+ } catch (Exception exception) {
+ // 售后和任务已经提交,首次执行基础设施异常不能反向把创建接口改成失败;调度器会继续扫描待执行任务。
+ log.error("|{}|首次同步执行异常,taskId:{},exceptionType:{}", TAG, taskId,
+ exception.getClass().getSimpleName(), exception);
+ }
+ };
+ if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+ action.run();
+ return;
+ }
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ /**
+ * 售后和任务本地事务提交完成后同步尝试资金退款。
+ */
+ @Override
+ public void afterCommit() {
+ action.run();
+ }
+ });
+ }
+
+ /**
+ * 使用订单 Redis 锁包裹尝试和失败状态落库,避免事务回滚释放行锁后被另一实例插入同一尝试序号。
+ *
+ * @param taskId 任务主键
+ * @param triggerType 触发来源
+ * @param operatorUserId 人工重试用户 ID
+ */
+ @Override
+ public void execute(Long taskId, HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId) {
+ HardAutoRefundTaskEntity current = getById(taskId);
+ if (current == null || Objects.equals(current.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) {
+ return;
+ }
+ LockInfo lock = lockTemplate.lock(REFUND_ORDER_LOCK_PREFIX + current.getOrderId(), REFUND_ORDER_LOCK_EXPIRE_MILLIS,
+ REFUND_ORDER_LOCK_WAIT_MILLIS);
+ if (lock == null) {
+ return;
+ }
+ try {
+ try {
+ getSelf().executeAttemptInTransaction(taskId, triggerType, operatorUserId);
+ } catch (CurrentConfigLookupException exception) {
+ if (!tryRecoverPersistedWalletRefund(taskId, triggerType, operatorUserId)) {
+ getSelf().pauseConfigUnavailableInTransaction(taskId, exception.getMessage());
+ }
+ } catch (Exception exception) {
+ handleAttemptFailure(taskId, triggerType, operatorUserId, exception);
+ }
+ } finally {
+ lockTemplate.releaseLock(lock);
+ }
+ }
+
+ /**
+ * 在单个 Hardware 事务中锁任务、复核配置、调用 Wallet 并原子完成售后和任务状态。
+ * 资金调用异常会使本事务全部回滚,外层随后用独立事务写失败日志。
+ *
+ * @param taskId 任务主键
+ * @param triggerType 触发来源
+ * @param operatorUserId 人工重试用户 ID
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public void executeAttemptInTransaction(Long taskId, HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId) {
+ HardAutoRefundTaskEntity task = baseMapper.selectByIdForUpdate(taskId);
+ if (!canExecute(task, triggerType)) {
+ return;
+ }
+ MchAutoRefundConfigVo current = loadCurrentConfig(task.getMchId(), SUPPORTED_ORDER_TYPE);
+ if (!matchesSnapshot(task.getConfigId(), task.getRuleType(), current)) {
+ if (!recoverPersistedWalletRefund(task, triggerType, operatorUserId)) {
+ pauseConfigUnavailable(task, "自动退款配置与订单快照不一致");
+ }
+ return;
+ }
+ if (!Objects.equals(current.getEnabled(), YesNoEnum.YES.getCode())) {
+ if (!recoverPersistedWalletRefund(task, triggerType, operatorUserId)) {
+ pauseForDisabledSwitch(task, triggerType, operatorUserId);
+ }
+ return;
+ }
+
+ int currentAutoAttempts = safeCount(task.getAutoAttemptCount());
+ if (triggerType.isAutoAttempt() && currentAutoAttempts >= MAX_AUTO_ATTEMPTS) {
+ if (!recoverPersistedWalletRefund(task, triggerType, operatorUserId)) {
+ task.setStatus(HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus());
+ task.setNextRetryTime(null);
+ baseMapper.updateById(task);
+ }
+ return;
+ }
+ completeAttempt(task, triggerType, operatorUserId, true);
+ }
+
+ /**
+ * 记录一次成功完成的 Wallet 调用并原子推进售后和任务状态。恢复已到账记录时不再消耗自动尝试额度,
+ * 因为真正的资金调用已在此前回滚的 Hardware 事务中发生。
+ *
+ * @param task 已锁定任务
+ * @param triggerType 触发来源
+ * @param operatorUserId 人工重试用户 ID
+ * @param countAutoAttempt 是否把本次调用计入八次自动资金尝试额度
+ */
+ private void completeAttempt(HardAutoRefundTaskEntity task, HardAutoRefundTriggerTypeEnum triggerType,
+ Long operatorUserId, boolean countAutoAttempt) {
+ int currentAutoAttempts = safeCount(task.getAutoAttemptCount());
+ int attemptNo = safeCount(task.getTotalAttemptCount()) + 1;
+ LocalDateTime startedTime = LocalDateTime.now();
+ HardAutoRefundAttemptLogEntity attempt = HardAutoRefundAttemptLogEntity.builder()
+ .taskId(task.getId())
+ .afterSaleId(task.getAfterSaleId())
+ .attemptNo(attemptNo)
+ .triggerType(triggerType.getType())
+ .resultStatus(HardAutoRefundAttemptResultEnum.EXECUTING.getStatus())
+ .refundCode(buildRefundCode(task))
+ .operatorUserId(operatorUserId)
+ .startedTime(startedTime)
+ .build();
+ if (attemptLogMapper.insert(attempt) != 1) {
+ throw new RuntimeException("创建自动退款尝试日志失败");
+ }
+ task.setTotalAttemptCount(attemptNo);
+ task.setAutoAttemptCount(countAutoAttempt && triggerType.isAutoAttempt() ? currentAutoAttempts + 1 : currentAutoAttempts);
+ task.setLastAttemptTime(startedTime);
+ task.setLastError(null);
+ if (baseMapper.updateById(task) != 1) {
+ throw new RuntimeException("更新自动退款尝试次数失败");
+ }
+
+ hardAfterSaleServiceProvider.getObject().completeAutoRefund(task.getAfterSaleId(), task.getConfigId());
+
+ LocalDateTime finishedTime = LocalDateTime.now();
+ attempt.setResultStatus(HardAutoRefundAttemptResultEnum.SUCCESS.getStatus());
+ attempt.setFinishedTime(finishedTime);
+ if (attemptLogMapper.updateById(attempt) != 1) {
+ throw new RuntimeException("更新自动退款尝试日志失败");
+ }
+ task.setStatus(HardAutoRefundTaskStatusEnum.SUCCESS.getStatus());
+ task.setNextRetryTime(null);
+ task.setSuccessTime(finishedTime);
+ task.setLastError(null);
+ if (baseMapper.updateById(task) != 1) {
+ throw new RuntimeException("更新自动退款成功状态失败");
+ }
+ }
+
+ /**
+ * 实际尝试失败后再次读取当前开关;若失败由关闭竞态导致则转为暂停且不消耗自动额度。
+ *
+ * @param taskId 任务主键
+ * @param triggerType 触发来源
+ * @param operatorUserId 人工重试用户 ID
+ * @param exception 本次执行异常
+ */
+ private void handleAttemptFailure(Long taskId, HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId,
+ Exception exception) {
+ HardAutoRefundTaskEntity task = getById(taskId);
+ if (task == null || Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) {
+ return;
+ }
+ // Wallet 可能已经提交资金事务而 Hardware 因超时或本地异常回滚,必须先恢复到账结果再判断是否暂停。
+ if (tryRecoverPersistedWalletRefund(taskId, triggerType, operatorUserId)) {
+ return;
+ }
+ try {
+ MchAutoRefundConfigVo current = loadCurrentConfig(task.getMchId(), SUPPORTED_ORDER_TYPE);
+ if (!matchesSnapshot(task.getConfigId(), task.getRuleType(), current)) {
+ getSelf().pauseConfigUnavailableInTransaction(taskId, "自动退款配置与订单快照不一致");
+ return;
+ }
+ if (!Objects.equals(current.getEnabled(), YesNoEnum.YES.getCode())) {
+ getSelf().pauseSwitchAfterFailureInTransaction(taskId, triggerType, operatorUserId);
+ return;
+ }
+ } catch (CurrentConfigLookupException configException) {
+ getSelf().pauseConfigUnavailableInTransaction(taskId, configException.getMessage());
+ return;
+ }
+ getSelf().recordFailureInTransaction(taskId, triggerType, operatorUserId, safeError(exception));
+ }
+
+ /**
+ * 在当前订单 Redis 锁内探测 Wallet 已到账记录,并通过独立 Hardware 事务恢复本地状态。
+ * 此方法直接调用事务代理,不递归进入 {@link #execute(Long, HardAutoRefundTriggerTypeEnum, Long)},避免重复获取同一订单锁。
+ *
+ * @param taskId 任务主键
+ * @param triggerType 触发来源
+ * @param operatorUserId 人工重试用户 ID
+ * @return Wallet 已存在退款记录并完成本地恢复时返回 {@code true}
+ */
+ private boolean tryRecoverPersistedWalletRefund(Long taskId, HardAutoRefundTriggerTypeEnum triggerType,
+ Long operatorUserId) {
+ HardAutoRefundTaskEntity task = getById(taskId);
+ if (!hasPersistedWalletRefund(task)) {
+ return false;
+ }
+ getSelf().recoverPersistedWalletRefundInTransaction(taskId, triggerType, operatorUserId);
+ return true;
+ }
+
+ /**
+ * 在独立 Hardware 事务中锁定任务并恢复 Wallet 已到账结果,不重新获取订单 Redis 锁。
+ * Wallet 的退款接口仍会按售后单 ID 严格校验钱包和金额后返回原记录。
+ *
+ * @param taskId 任务主键
+ * @param triggerType 触发来源
+ * @param operatorUserId 人工重试用户 ID
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public void recoverPersistedWalletRefundInTransaction(Long taskId, HardAutoRefundTriggerTypeEnum triggerType,
+ Long operatorUserId) {
+ HardAutoRefundTaskEntity task = baseMapper.selectByIdForUpdate(taskId);
+ if (task == null || Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) {
+ return;
+ }
+ completeAttempt(task, triggerType, operatorUserId, false);
+ }
+
+ /**
+ * 在当前 Hardware 事务中恢复已持久化的钱包退款。调用方已持有任务行锁,恢复过程不消耗自动额度。
+ *
+ * @param task 已锁定任务
+ * @param triggerType 触发来源
+ * @param operatorUserId 人工重试用户 ID
+ * @return Wallet 已存在退款记录并完成本地恢复时返回 {@code true}
+ */
+ private boolean recoverPersistedWalletRefund(HardAutoRefundTaskEntity task,
+ HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId) {
+ if (!hasPersistedWalletRefund(task)) {
+ return false;
+ }
+ completeAttempt(task, triggerType, operatorUserId, false);
+ return true;
+ }
+
+ /**
+ * 查询 Wallet 是否已按当前售后单持久化退款记录。查询异常按未确认到账处理并保留任务,禁止据此完成售后。
+ *
+ * @param task 自动退款任务
+ * @return Wallet 明确返回退款记录时返回 {@code true}
+ */
+ private boolean hasPersistedWalletRefund(HardAutoRefundTaskEntity task) {
+ if (task == null || task.getAfterSaleId() == null) {
+ return false;
+ }
+ try {
+ R result = remoteWalletRechargeService.getRefundByAfterSaleId(task.getAfterSaleId());
+ if (result == null || !result.isOk()) {
+ log.warn("|{}|查询钱包退款记录失败,taskId:{},afterSaleId:{}", TAG, task.getId(), task.getAfterSaleId());
+ return false;
+ }
+ return result.getData() != null;
+ } catch (Exception exception) {
+ log.warn("|{}|查询钱包退款记录异常,taskId:{},afterSaleId:{},exceptionType:{}", TAG, task.getId(),
+ task.getAfterSaleId(), exception.getClass().getSimpleName());
+ return false;
+ }
+ }
+
+ /**
+ * 独立事务记录一次真实资金尝试失败,并根据自动次数计算固定重试时间或耗尽状态。
+ *
+ * @param taskId 任务主键
+ * @param triggerType 触发来源
+ * @param operatorUserId 人工重试用户 ID
+ * @param errorMessage 已截断的非敏感错误信息
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public void recordFailureInTransaction(Long taskId, HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId,
+ String errorMessage) {
+ HardAutoRefundTaskEntity task = baseMapper.selectByIdForUpdate(taskId);
+ if (task == null || Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) {
+ return;
+ }
+ int attemptNo = safeCount(task.getTotalAttemptCount()) + 1;
+ int autoAttempts = safeCount(task.getAutoAttemptCount()) + (triggerType.isAutoAttempt() ? 1 : 0);
+ LocalDateTime now = LocalDateTime.now();
+ HardAutoRefundAttemptLogEntity attempt = HardAutoRefundAttemptLogEntity.builder()
+ .taskId(task.getId())
+ .afterSaleId(task.getAfterSaleId())
+ .attemptNo(attemptNo)
+ .triggerType(triggerType.getType())
+ .resultStatus(HardAutoRefundAttemptResultEnum.FAILED.getStatus())
+ .refundCode(buildRefundCode(task))
+ .operatorUserId(operatorUserId)
+ .startedTime(now)
+ .finishedTime(now)
+ .errorMessage(errorMessage)
+ .build();
+ attemptLogMapper.insert(attempt);
+
+ task.setTotalAttemptCount(attemptNo);
+ task.setAutoAttemptCount(autoAttempts);
+ task.setLastAttemptTime(now);
+ task.setLastError(errorMessage);
+ if (triggerType == HardAutoRefundTriggerTypeEnum.MANUAL || autoAttempts >= MAX_AUTO_ATTEMPTS) {
+ task.setStatus(HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus());
+ task.setNextRetryTime(null);
+ } else {
+ task.setStatus(HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus());
+ task.setNextRetryTime(now.plusMinutes(RETRY_DELAY_MINUTES[autoAttempts - 1]));
+ }
+ baseMapper.updateById(task);
+ log.warn("|{}|退款尝试失败,taskId:{},orderId:{},attemptNo:{},status:{}", TAG, task.getId(), task.getOrderId(),
+ attemptNo, task.getStatus());
+ }
+
+ /**
+ * 配置服务异常或配置快照不一致时暂停任务,不增加自动和总尝试次数。
+ *
+ * @param taskId 任务主键
+ * @param reason 暂停原因
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public void pauseConfigUnavailableInTransaction(Long taskId, String reason) {
+ HardAutoRefundTaskEntity task = baseMapper.selectByIdForUpdate(taskId);
+ if (task == null || Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) {
+ return;
+ }
+ pauseConfigUnavailable(task, reason);
+ }
+
+ /**
+ * 已发起调用后发现开关关闭时写一条跳过日志并暂停,不增加自动尝试次数。
+ *
+ * @param taskId 任务主键
+ * @param triggerType 触发来源
+ * @param operatorUserId 人工重试用户 ID
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public void pauseSwitchAfterFailureInTransaction(Long taskId, HardAutoRefundTriggerTypeEnum triggerType,
+ Long operatorUserId) {
+ HardAutoRefundTaskEntity task = baseMapper.selectByIdForUpdate(taskId);
+ if (task == null || Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) {
+ return;
+ }
+ pauseForDisabledSwitch(task, triggerType, operatorUserId);
+ }
+
+ /**
+ * 更新配置不可用暂停状态并设置下一轮复查时间。
+ *
+ * @param task 已锁定任务
+ * @param reason 暂停原因
+ */
+ private void pauseConfigUnavailable(HardAutoRefundTaskEntity task, String reason) {
+ task.setStatus(HardAutoRefundTaskStatusEnum.CONFIG_UNAVAILABLE.getStatus());
+ task.setNextRetryTime(LocalDateTime.now().plusSeconds(CONFIG_RECHECK_DELAY_SECONDS));
+ task.setLastError(truncate(reason));
+ baseMapper.updateById(task);
+ }
+
+ /**
+ * 首次遇到关闭开关时记录一次跳过审计,持续暂停扫描不会重复增加日志。
+ *
+ * @param task 已锁定任务
+ * @param triggerType 触发来源
+ * @param operatorUserId 人工重试用户 ID
+ */
+ private void pauseForDisabledSwitch(HardAutoRefundTaskEntity task, HardAutoRefundTriggerTypeEnum triggerType,
+ Long operatorUserId) {
+ if (!Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus())) {
+ int attemptNo = safeCount(task.getTotalAttemptCount()) + 1;
+ LocalDateTime now = LocalDateTime.now();
+ HardAutoRefundAttemptLogEntity attempt = HardAutoRefundAttemptLogEntity.builder()
+ .taskId(task.getId())
+ .afterSaleId(task.getAfterSaleId())
+ .attemptNo(attemptNo)
+ .triggerType(triggerType.getType())
+ .resultStatus(HardAutoRefundAttemptResultEnum.SWITCH_DISABLED.getStatus())
+ .refundCode(buildRefundCode(task))
+ .operatorUserId(operatorUserId)
+ .startedTime(now)
+ .finishedTime(now)
+ .build();
+ attemptLogMapper.insert(attempt);
+ task.setTotalAttemptCount(attemptNo);
+ task.setLastAttemptTime(now);
+ }
+ task.setStatus(HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus());
+ task.setNextRetryTime(null);
+ task.setLastError(null);
+ baseMapper.updateById(task);
+ }
+
+ /**
+ * 判断当前任务状态和触发来源是否允许进入资金尝试。
+ *
+ * @param task 已锁定任务
+ * @param triggerType 触发来源
+ * @return 允许执行时返回 {@code true}
+ */
+ private boolean canExecute(HardAutoRefundTaskEntity task, HardAutoRefundTriggerTypeEnum triggerType) {
+ if (task == null || Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) {
+ return false;
+ }
+ if (triggerType == HardAutoRefundTriggerTypeEnum.MANUAL) {
+ return Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus());
+ }
+ if (Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus())) {
+ return false;
+ }
+ if (Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus())
+ && task.getNextRetryTime() != null && task.getNextRetryTime().isAfter(LocalDateTime.now())) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * 查询当前配置并统一校验 Feign 业务响应,异常由调用方区分受理降级或任务暂停。
+ *
+ * @param mchId 商户 ID
+ * @param orderType 硬件订单类型编码
+ * @return 当前配置;未配置时返回 {@code null}
+ */
+ private MchAutoRefundConfigVo loadCurrentConfig(Long mchId, String orderType) {
+ try {
+ R result = remoteConfigService.getExact(mchId, orderType);
+ if (result == null || !result.isOk()) {
+ throw new CurrentConfigLookupException(result == null ? "自动退款配置服务无响应"
+ : StrUtil.blankToDefault(result.getMsg(), "自动退款配置查询失败"));
+ }
+ return result.getData();
+ } catch (CurrentConfigLookupException exception) {
+ throw exception;
+ } catch (Exception exception) {
+ throw new CurrentConfigLookupException("自动退款配置查询失败", exception);
+ }
+ }
+
+ /**
+ * 校验配置主键和规则仍与订单快照一致;开关单独判断以支持关闭暂停。
+ *
+ * @param configId 快照配置主键
+ * @param ruleType 快照规则名称
+ * @param current 当前 Wallet 配置
+ * @return 配置身份和规则一致时返回 {@code true}
+ */
+ private boolean matchesSnapshot(Long configId, String ruleType, MchAutoRefundConfigVo current) {
+ return current != null && Objects.equals(current.getId(), configId)
+ && Objects.equals(current.getRuleType(), ruleType)
+ && Objects.equals(current.getOrderType(), SUPPORTED_ORDER_TYPE);
+ }
+
+ /**
+ * 校验订单是否具备每日菜谱自动退款的完整快照和固定支付协议。
+ *
+ * @param order 原订单
+ * @return 快照完整且订单类型、支付方式符合首期范围时返回 {@code true}
+ */
+ private boolean hasValidSnapshot(HardOrderEntity order) {
+ return order != null && Objects.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())
+ && Objects.equals(order.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType())
+ && order.getAutoRefundConfigId() != null
+ && order.getAutoRefundRuleType() != null
+ && order.getAutoRefundDeadline() != null;
+ }
+
+ /**
+ * 分页查询任务并尽力补充关联展示数据;远程展示信息异常不会影响技术人员查看任务本体。
+ *
+ * @param page 分页参数
+ * @param dto 服务端已注入商户数据范围的筛选条件
+ * @return 任务分页
+ */
+ @Override
+ public Page pageTasks(Page page, HardAutoRefundTaskPageDto dto) {
+ Page entityPage = baseMapper.pageTasks(page, dto);
+ List records = BeanUtils.toBean(entityPage.getRecords(), HardAutoRefundTaskVo.class);
+ fillAfterSaleData(records);
+ fillUserData(records);
+ fillMerchantData(records);
+ Page result = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
+ result.setRecords(records);
+ return result;
+ }
+
+ /**
+ * 批量补充售后申请用户、核定金额和当前状态。
+ *
+ * @param records 当前页任务响应
+ */
+ private void fillAfterSaleData(List records) {
+ List ids = records.stream().map(HardAutoRefundTaskVo::getAfterSaleId).filter(Objects::nonNull).distinct().toList();
+ if (ids.isEmpty()) {
+ return;
+ }
+ Map map = afterSaleMapper.selectBatchIds(ids).stream()
+ .collect(Collectors.toMap(HardAfterSaleEntity::getId, Function.identity(), (first, second) -> first));
+ records.forEach(item -> {
+ HardAfterSaleEntity afterSale = map.get(item.getAfterSaleId());
+ if (afterSale != null) {
+ item.setUserId(afterSale.getUserId());
+ item.setRefundPrice(afterSale.getRefundPrice());
+ item.setAfterSaleStatus(afterSale.getStatus());
+ }
+ });
+ }
+
+ /**
+ * 批量补充用户名称,远程异常只记录警告并保留用户 ID。
+ *
+ * @param records 当前页任务响应
+ */
+ private void fillUserData(List records) {
+ List userIds = records.stream().map(HardAutoRefundTaskVo::getUserId).filter(Objects::nonNull).distinct().toList();
+ if (userIds.isEmpty()) {
+ return;
+ }
+ try {
+ R> response = remoteUserService.getUserById(new ArrayList<>(userIds));
+ if (response == null || !response.isOk() || response.getData() == null) {
+ return;
+ }
+ Map map = response.getData().stream().filter(Objects::nonNull)
+ .collect(Collectors.toMap(SysUser::getUserId, Function.identity(), (first, second) -> first));
+ records.forEach(item -> {
+ SysUser user = map.get(item.getUserId());
+ if (user != null) {
+ item.setUserName(StrUtil.blankToDefault(user.getName(),
+ StrUtil.blankToDefault(user.getNickname(), user.getUsername())));
+ }
+ });
+ } catch (Exception exception) {
+ log.warn("|{}|补充用户名称失败,exceptionType:{}", TAG, exception.getClass().getSimpleName());
+ }
+ }
+
+ /**
+ * 批量补充商户名称,远程异常只记录警告并保留商户 ID。
+ *
+ * @param records 当前页任务响应
+ */
+ private void fillMerchantData(List records) {
+ List mchIds = records.stream().map(HardAutoRefundTaskVo::getMchId).filter(Objects::nonNull).distinct().toList();
+ if (mchIds.isEmpty()) {
+ return;
+ }
+ try {
+ R> response = remoteMchService.getByIds(mchIds);
+ if (response == null || !response.isOk() || response.getData() == null) {
+ return;
+ }
+ Map map = response.getData().stream().filter(Objects::nonNull)
+ .collect(Collectors.toMap(MchManageVo::getId, Function.identity(), (first, second) -> first));
+ records.forEach(item -> {
+ MchManageVo merchant = map.get(item.getMchId());
+ if (merchant != null) {
+ item.setMchName(merchant.getName());
+ }
+ });
+ } catch (Exception exception) {
+ log.warn("|{}|补充商户名称失败,exceptionType:{}", TAG, exception.getClass().getSimpleName());
+ }
+ }
+
+ /**
+ * 查询只读尝试日志。
+ *
+ * @param taskId 任务主键
+ * @return 按尝试序号升序的日志列表
+ */
+ @Override
+ public List listAttempts(Long taskId) {
+ return attemptLogMapper.selectList(new LambdaQueryWrapper()
+ .eq(HardAutoRefundAttemptLogEntity::getTaskId, taskId)
+ .orderByAsc(HardAutoRefundAttemptLogEntity::getAttemptNo));
+ }
+
+ /**
+ * 仅允许重试耗尽任务进入人工额外尝试,重复点击仍由任务行锁复查状态。
+ *
+ * @param taskId 任务主键
+ * @param operatorUserId 当前管理用户 ID
+ */
+ @Override
+ public void manualRetry(Long taskId, Long operatorUserId) {
+ HardAutoRefundTaskEntity task = getById(taskId);
+ if (task == null) {
+ throw new RuntimeException("自动退款任务不存在");
+ }
+ if (!Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus())) {
+ throw new RuntimeException("仅重试耗尽任务允许人工重新触发");
+ }
+ execute(taskId, HardAutoRefundTriggerTypeEnum.MANUAL, operatorUserId);
+ }
+
+ /**
+ * 按售后唯一键查询任务。
+ *
+ * @param afterSaleId 售后单 ID
+ * @return 匹配任务;人工售后返回 {@code null}
+ */
+ @Override
+ public HardAutoRefundTaskEntity getByAfterSaleId(Long afterSaleId) {
+ if (afterSaleId == null) {
+ return null;
+ }
+ return baseMapper.selectOne(new LambdaQueryWrapper()
+ .eq(HardAutoRefundTaskEntity::getAfterSaleId, afterSaleId));
+ }
+
+ /**
+ * 售后单存在任意状态自动任务时均视为自动流程占用。
+ *
+ * @param afterSaleId 售后单 ID
+ * @return 存在任务时返回 {@code true}
+ */
+ @Override
+ public boolean existsByAfterSaleId(Long afterSaleId) {
+ return getByAfterSaleId(afterSaleId) != null;
+ }
+
+ /**
+ * 按订单快照、闭区间截止和实时配置判断当前申请是否可进入免审流程。
+ *
+ * @param order 原订单
+ * @return 当前满足免审条件时返回 {@code true}
+ */
+ @Override
+ public boolean isEligibleNow(HardOrderEntity order) {
+ if (!hasValidSnapshot(order) || LocalDateTime.now().isAfter(order.getAutoRefundDeadline())) {
+ return false;
+ }
+ try {
+ MchAutoRefundConfigVo current = loadCurrentConfig(order.getMerchantId(), order.getOrderType());
+ return matchesSnapshot(order.getAutoRefundConfigId(), order.getAutoRefundRuleType(), current)
+ && Objects.equals(current.getEnabled(), YesNoEnum.YES.getCode());
+ } catch (CurrentConfigLookupException exception) {
+ return false;
+ }
+ }
+
+ /**
+ * 转换 App 只读任务状态,不暴露 last_error。
+ *
+ * @param afterSaleId 售后单 ID
+ * @return App 任务状态;人工售后返回 {@code null}
+ */
+ @Override
+ public HardAppAutoRefundTaskVo getAppTaskStatus(Long afterSaleId) {
+ HardAutoRefundTaskEntity task = getByAfterSaleId(afterSaleId);
+ if (task == null) {
+ return null;
+ }
+ HardAppAutoRefundTaskVo result = new HardAppAutoRefundTaskVo();
+ result.setAfterSaleId(task.getAfterSaleId());
+ result.setStatus(task.getStatus());
+ result.setAutoAttemptCount(task.getAutoAttemptCount());
+ result.setNextRetryTime(task.getNextRetryTime());
+ result.setSuccessTime(task.getSuccessTime());
+ return result;
+ }
+
+ /**
+ * 先选择到期的新建/重试任务,再用剩余额度选择暂停任务,保证大量关闭商户不会饿死资金重试。
+ *
+ * @param limit 最大扫描数量
+ * @return 本轮待复查任务列表
+ */
+ @Override
+ public List listScanCandidates(int limit) {
+ if (limit <= 0) {
+ return List.of();
+ }
+ LocalDateTime now = LocalDateTime.now();
+ List result = new ArrayList<>(baseMapper.selectList(
+ new LambdaQueryWrapper()
+ .and(wrapper -> wrapper.eq(HardAutoRefundTaskEntity::getStatus,
+ HardAutoRefundTaskStatusEnum.PENDING.getStatus())
+ .or(nested -> nested.eq(HardAutoRefundTaskEntity::getStatus,
+ HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus())
+ .le(HardAutoRefundTaskEntity::getNextRetryTime, now)))
+ .orderByAsc(HardAutoRefundTaskEntity::getNextRetryTime)
+ .orderByAsc(HardAutoRefundTaskEntity::getId)
+ .last("limit " + limit)));
+ int remaining = limit - result.size();
+ if (remaining <= 0) {
+ return result;
+ }
+ result.addAll(baseMapper.selectList(new LambdaQueryWrapper()
+ .in(HardAutoRefundTaskEntity::getStatus, List.of(
+ HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus(),
+ HardAutoRefundTaskStatusEnum.CONFIG_UNAVAILABLE.getStatus()))
+ .and(wrapper -> wrapper.isNull(HardAutoRefundTaskEntity::getNextRetryTime)
+ .or().le(HardAutoRefundTaskEntity::getNextRetryTime, now))
+ .orderByAsc(HardAutoRefundTaskEntity::getUpdateTime)
+ .orderByAsc(HardAutoRefundTaskEntity::getId)
+ .last("limit " + remaining)));
+ return result;
+ }
+
+ /**
+ * 构造 Wallet 和尝试日志共同使用的稳定退款业务号。
+ *
+ * @param task 自动退款任务
+ * @return hard_{orderId}_{afterSaleId}
+ */
+ private String buildRefundCode(HardAutoRefundTaskEntity task) {
+ return "hard_" + task.getOrderId() + "_" + task.getAfterSaleId();
+ }
+
+ /**
+ * 规范化可空计数,历史空值按零处理。
+ *
+ * @param value 可空计数
+ * @return 非负计数
+ */
+ private int safeCount(Integer value) {
+ return value == null ? 0 : Math.max(0, value);
+ }
+
+ /**
+ * 提取非敏感异常消息并限制数据库字段长度。
+ *
+ * @param exception 本次尝试异常
+ * @return 最多 2000 字符的诊断信息
+ */
+ private String safeError(Exception exception) {
+ String message = StrUtil.blankToDefault(exception.getMessage(), exception.getClass().getSimpleName());
+ return truncate(message);
+ }
+
+ /**
+ * 截断任务诊断信息,避免数据库字段溢出。
+ *
+ * @param value 原始诊断信息
+ * @return 最多 2000 字符的文本
+ */
+ private String truncate(String value) {
+ if (value == null || value.length() <= MAX_ERROR_LENGTH) {
+ return value;
+ }
+ return value.substring(0, MAX_ERROR_LENGTH);
+ }
+
+ /**
+ * 获取当前 Spring 代理,保证类内调用的事务方法经过事务拦截器。
+ *
+ * @return 当前任务服务代理
+ */
+ private HardAutoRefundTaskServiceImpl getSelf() {
+ return SpringUtil.getBean(getClass());
+ }
+
+ /**
+ * 配置服务调用或业务响应失败的内部异常,用于与真实资金尝试失败区分且不消耗重试额度。
+ */
+ private static final class CurrentConfigLookupException extends RuntimeException {
+
+ /**
+ * 使用业务提示构造配置查询异常。
+ *
+ * @param message 不包含敏感信息的失败原因
+ */
+ private CurrentConfigLookupException(String message) {
+ super(message);
+ }
+
+ /**
+ * 使用业务提示和原异常构造配置查询异常。
+ *
+ * @param message 不包含敏感信息的失败原因
+ * @param cause 原始远程异常
+ */
+ private CurrentConfigLookupException(String message, Throwable cause) {
+ super(message, cause);
+ }
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
index 338bfa58f..2c67b6676 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
@@ -113,6 +113,7 @@ import com.bsd.pjcloud.wallet.api.constant.WalletCodeBizEnum;
import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
import com.bsd.pjcloud.wallet.api.constant.act.ActCancelStatusEnum;
import com.bsd.pjcloud.wallet.api.constant.mch.WalletMchRulePriceTypeEnum;
+import com.bsd.pjcloud.wallet.api.constant.mch.MchAutoRefundRuleTypeEnum;
import com.bsd.pjcloud.wallet.api.constant.mch.WalletMchSupType;
import com.bsd.pjcloud.wallet.api.constant.mch.WalletMchTableReservationMode;
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
@@ -132,6 +133,7 @@ import com.bsd.pjcloud.wallet.api.vo.act.ActCancelItemVo;
import com.bsd.pjcloud.wallet.api.vo.act.ActComItemVo;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPickUpVo;
import com.bsd.pjcloud.wallet.api.vo.mch.MchRuleJoinDtoVo;
+import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -195,6 +197,9 @@ public class HardOrderServiceImpl extends ServiceImpl dinerUserIds = validateReserveDinerUserIds(payerUserId, dto.getDinerUserIds());
HardParentCarEntity sourceParentCar = hardCarService.getParentCarById(dto.getCarParentId());
if (sourceParentCar == null || !Objects.equals(sourceParentCar.getUserId(), payerUserId)) {
@@ -1323,10 +1331,6 @@ public class HardOrderServiceImpl extends ServiceImpl new RuntimeException("用户钱包不存在"));
- if (!Objects.equals(wallet.getUserId(), payerUserId)) {
- throw new RuntimeException("钱包不属于当前付款人");
- }
List dinerUsers = Optional.ofNullable(userService.getInnerUserById(dinerUserIds)).map(R::getData).orElseThrow(() -> new RuntimeException("查询用餐人失败"));
Map dinerUserMap = dinerUsers.stream().collect(Collectors.toMap(SysUser::getUserId, Function.identity(), (first, second) -> first));
if (dinerUserMap.size() != dinerUserIds.size()) {
@@ -1343,35 +1347,40 @@ public class HardOrderServiceImpl extends ServiceImpl dinerWalletMap = resolveReserveDinerWallets(dinerUserIds, dinerUserMap, singlePrice);
Map inventorySnapshot = validateReserveBatchInventory(sourceParentCar.getMerchantId(), sourceCarList, dinerUserIds.size());
String subject = buildReserveOrderSubject(dto.getCarParentId(), sourceCarList);
// 在所有远程预检完成后再占用餐段锁,缩短并发请求等待时间;锁仍覆盖查询、落单与事务提交。
validateSingleReservationOnlyPerMeal(merchant, dinerUserIds, reservation);
List createdOrders = new ArrayList<>();
+ String inventoryBizId = null;
+ boolean inventoryOperationAttempted = false;
try {
for (int index = 0; index < dinerUserIds.size(); index++) {
Long dinerUserId = dinerUserIds.get(index);
Long parentCarId = index == 0 ? sourceParentCar.getId() : cloneReserveCart(sourceParentCar, sourceCarList, payerUserId);
- HardOrderEntity order = buildReserveOrder(dinerUserMap.get(dinerUserId), parentCarId, merchant, wallet, reservation, singlePrice, subject);
+ HardOrderEntity order = buildReserveOrder(dinerUserMap.get(dinerUserId), parentCarId, merchant,
+ dinerWalletMap.get(dinerUserId), reservation, singlePrice, subject);
if (baseMapper.insert(order) != 1) {
throw new RuntimeException("创建订餐订单失败");
}
createdOrders.add(order);
}
// 一次性按用餐人数扣减汇总库存,避免逐单扣减中途失败产生临时部分占用。
- updateReserveBatchInventory(sourceCarList, dinerUserIds.size(), true, inventorySnapshot);
+ inventoryBizId = buildReserveInventoryBizId(createdOrders);
+ inventoryOperationAttempted = true;
+ updateReserveBatchInventory(sourceCarList, dinerUserIds.size(), true, inventorySnapshot, inventoryBizId);
+ registerReserveOrderRollbackCompensation(createdOrders, sourceCarList, dinerUserIds.size(), inventorySnapshot, inventoryBizId);
+ reduceReserveOrderBalances(createdOrders);
for (HardOrderEntity order : createdOrders) {
- reduceReserveOrderBalance(order);
markReserveOrderPaid(order);
}
} catch (Exception exception) {
// 库存接口以整批原子事务执行,但远程响应超时后调用方无法判断是否已经提交;
// 因此无论扣减调用在哪一步失败,都按同一批订单号发起一次幂等恢复。
- compensateReserveOrderBatch(createdOrders, sourceCarList, dinerUserIds.size(), inventorySnapshot);
+ compensateReserveOrderBatch(createdOrders, sourceCarList, dinerUserIds.size(), inventorySnapshot,
+ inventoryOperationAttempted ? inventoryBizId : null);
throw exception;
}
@@ -1405,11 +1414,11 @@ public class HardOrderServiceImpl extends ServiceImpl contexts = loadDailyMenuReserveGroupContexts(payerUserId, groups);
Long merchantId = contexts.get(0).parentCar().getMerchantId();
MchManageVo merchant = validateDailyMenuReserveMerchant(merchantId);
+ MchAutoRefundConfigVo autoRefundConfig = loadDailyMenuAutoRefundConfig(merchantId);
validateMerchantReserveDinerPermissions(payerUserId, dinerUserIds, merchant);
validateDailyMenuCartProducts(merchantId, contexts);
validateDailyMenuSingleProductLimit(merchant, contexts);
- WalletManageEntity wallet = getDailyMenuPaymentWallet(payerUserId, dto.getWalletTypeId());
List dinerUsers = Optional.ofNullable(userService.getInnerUserById(dinerUserIds))
.map(R::getData)
.orElseThrow(() -> new RuntimeException("查询用餐人失败"));
@@ -1434,9 +1443,7 @@ public class HardOrderServiceImpl extends ServiceImpl dinerWalletMap = resolveReserveDinerWallets(dinerUserIds, dinerUserMap, singleDinerTotalPrice);
// 固定按日期和餐段顺序获取冲突锁,避免两个批量请求以相反分组顺序互相等待。
contexts.stream()
@@ -1454,19 +1461,19 @@ public class HardOrderServiceImpl extends ServiceImpl createdOrders) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
@@ -1506,6 +1513,42 @@ public class HardOrderServiceImpl extends ServiceImpl createdOrders,
+ List sourceCarList,
+ int dinerCount,
+ Map inventorySnapshot,
+ String inventoryBizId) {
+ if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+ throw new RuntimeException("订餐事务状态异常,请稍后重试");
+ }
+ List rollbackOrders = List.copyOf(createdOrders);
+ List rollbackCars = List.copyOf(sourceCarList);
+ Map rollbackInventory = Map.copyOf(inventorySnapshot);
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ /**
+ * 本地事务明确回滚时恢复库存并补偿已存在的远程钱包支付流水。
+ *
+ * @param status Spring 事务完成状态
+ */
+ @Override
+ public void afterCompletion(int status) {
+ if (status == TransactionSynchronization.STATUS_ROLLED_BACK) {
+ compensateReserveOrderBatch(rollbackOrders, rollbackCars, dinerCount, rollbackInventory, inventoryBizId);
+ }
+ }
+ });
+ }
+
/**
* 规范化每日菜谱分组并拒绝重复父购物车,避免同一购物车在一个批次内被创建多次订单。
*
@@ -1675,22 +1718,128 @@ public class HardOrderServiceImpl extends ServiceImpl result = remoteWalletService.getWalletByWalletTypeId(payerUserId, walletTypeId);
- WalletManageEntity wallet = result == null || !result.isOk() ? null : result.getData();
- if (wallet == null) {
- throw new RuntimeException(result == null ? "查询用户钱包无响应" : StrUtil.blankToDefault(result.getMsg(), "用户钱包不存在"));
+ private MchAutoRefundConfigVo loadDailyMenuAutoRefundConfig(Long merchantId) {
+ try {
+ R response = remoteMchAutoRefundConfigService.getExact(merchantId,
+ HardOrderTypeEnum.RESERVE.getType());
+ if (response == null || !response.isOk() || response.getData() == null) {
+ return null;
+ }
+ MchAutoRefundConfigVo config = response.getData();
+ if (!Objects.equals(config.getEnabled(), YesNoEnum.YES.getCode())
+ || !Objects.equals(config.getMchId(), merchantId)
+ || !Objects.equals(config.getOrderType(), HardOrderTypeEnum.RESERVE.getType())) {
+ return null;
+ }
+ MchAutoRefundRuleTypeEnum rule = MchAutoRefundRuleTypeEnum.valueOfName(config.getRuleType());
+ if (rule == null) {
+ return null;
+ }
+ if (rule == MchAutoRefundRuleTypeEnum.PAY_TIME_LIMIT
+ && (config.getPayTimeLimitHours() == null || config.getPayTimeLimitHours() < 1
+ || config.getPayTimeLimitHours() > 168)) {
+ return null;
+ }
+ return config;
+ } catch (Exception exception) {
+ log.warn("|{}|查询每日菜谱自动退款配置失败,mchId:|{}|,exceptionType:|{}|", TAG, merchantId,
+ exception.getClass().getSimpleName());
+ return null;
}
- if (!Objects.equals(wallet.getUserId(), payerUserId)) {
- throw new RuntimeException("钱包不属于当前付款人");
+ }
+
+ /**
+ * 把下单时命中的配置身份、规则和可确定截止时间写入订单。周期规则缺少有效批次时整组快照留空。
+ *
+ * @param order 尚未入库的每日菜谱订单
+ * @param reservation 已校验预约上下文
+ * @param config 本批次唯一配置查询结果;为空时不写快照
+ */
+ private void applyDailyMenuAutoRefundSnapshot(HardOrderEntity order,
+ HardApptReservationService.ReservationValidation reservation,
+ MchAutoRefundConfigVo config) {
+ if (config == null) {
+ return;
}
- return wallet;
+ MchAutoRefundRuleTypeEnum rule = MchAutoRefundRuleTypeEnum.valueOfName(config.getRuleType());
+ if (rule == MchAutoRefundRuleTypeEnum.APPT_CYCLE_CLOSE
+ && (reservation.apptRuleId() == null || reservation.eligibilityDeadline() == null)) {
+ return;
+ }
+ order.setAutoRefundConfigId(config.getId());
+ order.setAutoRefundRuleType(config.getRuleType());
+ if (rule == MchAutoRefundRuleTypeEnum.APPT_CYCLE_CLOSE) {
+ order.setAutoRefundDeadline(reservation.eligibilityDeadline());
+ }
+ }
+
+ /**
+ * 批量解析实际用餐人的 EAT 饭卡并校验每人本批次应付金额。客户端不提交钱包 ID,避免代订人替换被监护人的扣款目标。
+ *
+ * @param dinerUserIds 保持用户选择顺序的实际用餐人 ID
+ * @param dinerUserMap 服务端查询得到的实际用餐人资料
+ * @param requiredPricePerDiner 每位用餐人本批次应付金额,单位为分
+ * @return 按实际用餐人 ID 索引的本人 EAT 饭卡
+ */
+ private Map resolveReserveDinerWallets(List dinerUserIds,
+ Map dinerUserMap,
+ int requiredPricePerDiner) {
+ if (requiredPricePerDiner <= 0) {
+ throw new RuntimeException("订餐金额异常");
+ }
+ R> result;
+ try {
+ result = remoteWalletService.getWalletsByUserIds(dinerUserIds, WalletTypeEnum.EAT);
+ } catch (Exception exception) {
+ throw new RuntimeException("查询用餐人饭卡失败,请稍后重试", exception);
+ }
+ if (result == null || !result.isOk() || result.getData() == null) {
+ throw new RuntimeException(result == null ? "查询用餐人饭卡无响应"
+ : StrUtil.blankToDefault(result.getMsg(), "查询用餐人饭卡失败"));
+ }
+ Map walletMap = new LinkedHashMap<>();
+ for (WalletManageEntity wallet : result.getData()) {
+ if (wallet == null || wallet.getId() == null || wallet.getUserId() == null
+ || !dinerUserMap.containsKey(wallet.getUserId())) {
+ throw new RuntimeException("用餐人饭卡数据异常,请联系管理员");
+ }
+ if (walletMap.putIfAbsent(wallet.getUserId(), wallet) != null) {
+ throw new RuntimeException(reserveDinerDisplayName(dinerUserMap.get(wallet.getUserId())) + "存在重复饭卡数据,请联系管理员");
+ }
+ }
+ for (Long dinerUserId : dinerUserIds) {
+ SysUser dinerUser = dinerUserMap.get(dinerUserId);
+ WalletManageEntity wallet = walletMap.get(dinerUserId);
+ if (wallet == null) {
+ throw new RuntimeException(reserveDinerDisplayName(dinerUser) + "未开通饭卡");
+ }
+ if (!Objects.equals(wallet.getUserId(), dinerUserId)) {
+ throw new RuntimeException(reserveDinerDisplayName(dinerUser) + "的饭卡归属异常,请联系管理员");
+ }
+ if (wallet.getBalance() == null || wallet.getBalance() < requiredPricePerDiner) {
+ throw new RuntimeException(reserveDinerDisplayName(dinerUser) + "饭卡余额不足");
+ }
+ }
+ return walletMap;
+ }
+
+ /**
+ * 按姓名、昵称、用户名顺序生成人员提示,确保多人预检失败时用户能定位需要处理的饭卡。
+ *
+ * @param dinerUser 实际用餐人资料
+ * @return 可用于异常提示的稳定名称
+ */
+ private String reserveDinerDisplayName(SysUser dinerUser) {
+ if (dinerUser == null) {
+ return "用餐人";
+ }
+ return StrUtil.blankToDefault(dinerUser.getName(),
+ StrUtil.blankToDefault(dinerUser.getNickname(), StrUtil.blankToDefault(dinerUser.getUsername(), "用餐人")));
}
/**
@@ -1962,21 +2111,40 @@ public class HardOrderServiceImpl extends ServiceImpl result = walletTransactionService.reduceWalletBalanceIdempotent(reduceDto);
- if (result == null || !result.isOk() || result.getData() == null) {
- throw new RuntimeException(result == null ? "钱包扣款无响应" : result.getMsg());
+ private void reduceReserveOrderBalances(List orders) {
+ List paymentList = buildReserveWalletTransactions(orders);
+ R> result = walletTransactionService.reduceWalletBalanceBatchIdempotent(paymentList);
+ if (result == null || !result.isOk() || result.getData() == null || result.getData().size() != paymentList.size()) {
+ throw new RuntimeException(result == null ? "钱包批量扣款无响应"
+ : StrUtil.blankToDefault(result.getMsg(), "钱包批量扣款结果异常"));
}
}
+ /**
+ * 将预约订单转换为钱包支付或补偿使用的稳定明细,同一订单 ID 在扣款与补偿阶段始终使用同一业务号。
+ *
+ * @param orders 已创建的预约订单
+ * @return 与订单顺序一致的钱包支付明细
+ */
+ private List buildReserveWalletTransactions(List orders) {
+ return orders.stream().map(order -> {
+ if (order.getId() == null || order.getWalletId() == null || order.getPayPrice() == null || order.getPayPrice() <= 0) {
+ throw new RuntimeException("订餐钱包交易数据异常");
+ }
+ WalletBalanceTransactionDto transaction = new WalletBalanceTransactionDto();
+ transaction.setWalletId(order.getWalletId());
+ transaction.setPrice(order.getPayPrice());
+ transaction.setBizId(String.valueOf(order.getId()));
+ transaction.setBizType(WalletBizEnum.PAYMENT);
+ transaction.setForce(false);
+ return transaction;
+ }).toList();
+ }
+
/**
* 扣库存前按整批人数汇总每个商品的需求,先拒绝商品缺失、跨商户和库存不足。
*/
@@ -2036,7 +2204,23 @@ public class HardOrderServiceImpl extends ServiceImpl sourceCarList, int dinerCount, boolean reduce, Map inventorySnapshot) {
+ private void updateReserveBatchInventory(List sourceCarList, int dinerCount, boolean reduce,
+ Map inventorySnapshot, String inventoryBizId) {
// DTO 始终携带原始扣减量;食堂服务根据 operation 决定应用扣减还是反向恢复。
int factor = -dinerCount;
CanteenSpuUpdateStockReqDTO stockDto = new CanteenSpuUpdateStockReqDTO();
- stockDto.setBizId(buildReserveInventoryBizId(sourceCarList));
+ stockDto.setBizId(inventoryBizId);
stockDto.setOperation(reduce ? "DEDUCT" : "ROLLBACK");
Map stockChanges = sourceCarList.stream().collect(Collectors.toMap(
HardCarEntity::getProductId,
@@ -2112,22 +2319,38 @@ public class HardOrderServiceImpl extends ServiceImpl sourceCarList) {
- Long sourceParentCarId = sourceCarList.stream()
- .map(HardCarEntity::getParentCarId)
+ private String buildReserveInventoryBizId(List createdOrders) {
+ Long firstOrderId = createdOrders.stream()
+ .map(HardOrderEntity::getId)
.filter(Objects::nonNull)
.findFirst()
- .orElseThrow(() -> new RuntimeException("购物车数据异常"));
- return "HARD_RESERVE_CART:" + sourceParentCarId;
+ .orElseThrow(() -> new RuntimeException("订餐订单批次数据异常"));
+ return "HARD_RESERVE_ORDER_BATCH:" + firstOrderId;
}
- private void compensateReserveOrderBatch(List createdOrders, List sourceCarList, int dinerCount, Map inventorySnapshot) {
- try {
- updateReserveBatchInventory(sourceCarList, dinerCount, false, inventorySnapshot);
- } catch (Exception compensationException) {
- log.error("|{}|多人订餐库存补偿失败", TAG, compensationException);
+ /**
+ * 补偿旧线上订餐已经尝试的跨服务库存与钱包操作;尚未发起库存请求时不写入远程回滚状态。
+ *
+ * @param createdOrders 已分配 ID 的本次订单
+ * @param sourceCarList 原始购物车商品
+ * @param dinerCount 实际用餐人数
+ * @param inventorySnapshot 扣减前库存快照
+ * @param inventoryBizId 已尝试扣库存时的批次号;未尝试时为空
+ */
+ private void compensateReserveOrderBatch(List createdOrders, List sourceCarList,
+ int dinerCount, Map inventorySnapshot, String inventoryBizId) {
+ if (StrUtil.isNotBlank(inventoryBizId)) {
+ try {
+ updateReserveBatchInventory(sourceCarList, dinerCount, false, inventorySnapshot, inventoryBizId);
+ } catch (Exception compensationException) {
+ log.error("|{}|多人订餐库存补偿失败,inventoryBizId:|{}|", TAG, inventoryBizId, compensationException);
+ }
}
compensateReserveOrderWallets(createdOrders);
// 克隆购物车与订单处于同一本地事务中,会随订单一并回滚;原购物车刻意保留,便于整批失败后重试。
@@ -2139,24 +2362,18 @@ public class HardOrderServiceImpl extends ServiceImpl createdOrders) {
- // 对所有已创建订单发起幂等补偿;钱包服务只会退回实际存在的 PAYMENT 流水。
- ListIterator walletIterator = createdOrders.listIterator(createdOrders.size());
- while (walletIterator.hasPrevious()) {
- HardOrderEntity order = walletIterator.previous();
- try {
- WalletBalanceTransactionDto refundDto = new WalletBalanceTransactionDto();
- refundDto.setWalletId(order.getWalletId());
- refundDto.setPrice(order.getPayPrice());
- refundDto.setBizId(String.valueOf(order.getId()));
- refundDto.setBizType(WalletBizEnum.PAYMENT);
- refundDto.setForce(false);
- R result = walletTransactionService.compensatePayment(refundDto);
- if (result == null || !result.isOk() || !Boolean.TRUE.equals(result.getData())) {
- throw new RuntimeException(result == null ? "钱包补偿无响应" : result.getMsg());
- }
- } catch (Exception compensationException) {
- log.error("|{}|多人订餐钱包补偿失败,orderId:|{}|", TAG, order.getId(), compensationException);
+ if (createdOrders.isEmpty()) {
+ return;
+ }
+ try {
+ R result = walletTransactionService.compensatePaymentBatch(buildReserveWalletTransactions(createdOrders));
+ if (result == null || !result.isOk() || !Boolean.TRUE.equals(result.getData())) {
+ throw new RuntimeException(result == null ? "钱包批量补偿无响应"
+ : StrUtil.blankToDefault(result.getMsg(), "钱包批量补偿失败"));
}
+ } catch (Exception compensationException) {
+ log.error("|{}|多人订餐钱包批量补偿失败,orderIds:|{}|", TAG,
+ createdOrders.stream().map(HardOrderEntity::getId).toList(), compensationException);
}
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml
index 240fa0b8c..7d389be70 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml
@@ -33,6 +33,9 @@
a.appt_id,
a.appt_rule_id,
a.appt_use_date,
+ a.auto_refund_config_id,
+ a.auto_refund_rule_type,
+ a.auto_refund_deadline,
a.sys_type,
a.total_price,
a.extra_price,
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJobTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJobTest.java
new file mode 100644
index 000000000..3c743815c
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJobTest.java
@@ -0,0 +1,62 @@
+package com.bsd.pjcloud.hard.job;
+
+import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
+import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
+import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
+import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 自动退款补偿调度器的批量上限和租户上下文恢复测试。
+ */
+@ExtendWith(MockitoExtension.class)
+class HardAutoRefundTaskJobTest {
+
+ /**
+ * 自动退款任务服务 Mock。
+ */
+ @Mock
+ private HardAutoRefundTaskService taskService;
+
+ /**
+ * 被测自动退款调度器。
+ */
+ @InjectMocks
+ private HardAutoRefundTaskJob job;
+
+ /**
+ * 清理测试线程租户上下文,避免影响同进程其他用例。
+ */
+ @AfterEach
+ void tearDown() {
+ TenantContextHolder.setTenantId(null);
+ }
+
+ /**
+ * 单轮应最多请求一百条任务,并在任务租户上下文中触发定时重试。
+ */
+ @Test
+ void scheduledScanShouldRestoreTaskTenantBeforeExecution() {
+ HardAutoRefundTaskEntity task = new HardAutoRefundTaskEntity();
+ task.setId(10L);
+ task.setTenantId(66L);
+ when(taskService.listScanCandidates(100)).thenReturn(List.of(task));
+
+ job.retryDueTasks();
+
+ verify(taskService).listScanCandidates(100);
+ verify(taskService).execute(10L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null);
+ assertNull(TenantContextHolder.getTenantId());
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java
index 5386cf610..cfa57c56a 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java
@@ -299,8 +299,10 @@ class HardApptReservationServiceImplTest {
HardApptReservationServiceImpl service = reservationService(weekly, 0, List.of(rule));
LocalDateTime now = LocalDateTime.of(2026, 7, 24, 18, 0);
- assertEquals(100L, service.validateReservation(10L, null, 100L, "1",
- LocalDate.of(2026, 7, 28), null, now).apptRuleId());
+ var validation = service.validateReservation(10L, null, 100L, "1",
+ LocalDate.of(2026, 7, 28), null, now);
+ assertEquals(100L, validation.apptRuleId());
+ assertEquals(LocalDateTime.of(2026, 7, 26, 22, 0), validation.eligibilityDeadline());
assertThrows(RuntimeException.class, () -> service.validateReservation(10L, null, 999L, "1",
LocalDate.of(2026, 7, 28), null, now));
assertThrows(RuntimeException.class, () -> service.validateReservation(10L, null, 100L, "1",
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java
new file mode 100644
index 000000000..5a9aa20cd
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java
@@ -0,0 +1,180 @@
+package com.bsd.pjcloud.hard.service.impl.order;
+
+import com.baomidou.lock.LockTemplate;
+import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
+import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum;
+import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
+import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity;
+import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
+import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleMapper;
+import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundAttemptLogMapper;
+import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundTaskMapper;
+import com.bsd.pjcloud.hard.service.HardAfterSaleService;
+import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity;
+import com.bsd.pjcloud.wallet.api.feign.RemoteMchAutoRefundConfigService;
+import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
+import com.bsd.pjcloud.wallet.api.feign.RemoteWalletRechargeService;
+import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.LocalDateTime;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Hardware 自动退款失败计数、固定重试间隔和耗尽状态测试。
+ */
+@ExtendWith(MockitoExtension.class)
+class HardAutoRefundTaskServiceImplTest {
+
+ /** 自动退款尝试日志 Mapper Mock。 */
+ @Mock
+ private HardAutoRefundAttemptLogMapper attemptLogMapper;
+ /** 售后 Mapper Mock。 */
+ @Mock
+ private HardAfterSaleMapper afterSaleMapper;
+ /** 售后服务延迟提供器 Mock。 */
+ @Mock
+ private ObjectProvider afterSaleServiceProvider;
+ /** Wallet 自动退款配置远程服务 Mock。 */
+ @Mock
+ private RemoteMchAutoRefundConfigService configService;
+ /** Wallet 退款记录远程服务 Mock。 */
+ @Mock
+ private RemoteWalletRechargeService walletRechargeService;
+ /** 商户远程服务 Mock。 */
+ @Mock
+ private RemoteMchService mchService;
+ /** 用户远程服务 Mock。 */
+ @Mock
+ private RemoteUserService userService;
+ /** 订单分布式锁模板 Mock。 */
+ @Mock
+ private LockTemplate lockTemplate;
+ /** 自动退款任务 Mapper Mock。 */
+ @Mock
+ private HardAutoRefundTaskMapper taskMapper;
+
+ /** 被测自动退款任务服务。 */
+ private HardAutoRefundTaskServiceImpl service;
+
+ /**
+ * 初始化服务并注入 ServiceImpl 的任务 Mapper。
+ */
+ @BeforeEach
+ void setUp() {
+ service = new HardAutoRefundTaskServiceImpl(attemptLogMapper, afterSaleMapper, afterSaleServiceProvider,
+ configService, walletRechargeService, mchService, userService, lockTemplate);
+ ReflectionTestUtils.setField(service, "baseMapper", taskMapper);
+ when(attemptLogMapper.insert(any(HardAutoRefundAttemptLogEntity.class))).thenReturn(1);
+ when(taskMapper.updateById(any(HardAutoRefundTaskEntity.class))).thenReturn(1);
+ }
+
+ /**
+ * 首次同步执行失败应计为第一次自动尝试并在一分钟后进入等待重试。
+ */
+ @Test
+ void firstFailureShouldScheduleOneMinuteRetry() {
+ HardAutoRefundTaskEntity task = task(0, 0);
+ when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task);
+ LocalDateTime before = LocalDateTime.now();
+
+ service.recordFailureInTransaction(1L, HardAutoRefundTriggerTypeEnum.INITIAL, null, "wallet timeout");
+
+ LocalDateTime after = LocalDateTime.now();
+ assertEquals(1, task.getAutoAttemptCount());
+ assertEquals(1, task.getTotalAttemptCount());
+ assertEquals(HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus(), task.getStatus());
+ assertTrue(!task.getNextRetryTime().isBefore(before.plusMinutes(1))
+ && !task.getNextRetryTime().isAfter(after.plusMinutes(1)));
+ }
+
+ /**
+ * 第八次自动失败应进入重试耗尽且不再保留下次执行时间。
+ */
+ @Test
+ void eighthAutomaticFailureShouldBecomeExhausted() {
+ HardAutoRefundTaskEntity task = task(7, 7);
+ when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task);
+
+ service.recordFailureInTransaction(1L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null, "wallet timeout");
+
+ assertEquals(8, task.getAutoAttemptCount());
+ assertEquals(8, task.getTotalAttemptCount());
+ assertEquals(HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus(), task.getStatus());
+ assertNull(task.getNextRetryTime());
+ }
+
+ /**
+ * 人工技术重试失败只增加总尝试次数,不增加八次自动额度并保持耗尽告警。
+ */
+ @Test
+ void manualFailureShouldRemainExhaustedWithoutIncreasingAutoCount() {
+ HardAutoRefundTaskEntity task = task(8, 8);
+ when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task);
+
+ service.recordFailureInTransaction(1L, HardAutoRefundTriggerTypeEnum.MANUAL, 99L, "wallet timeout");
+
+ assertEquals(8, task.getAutoAttemptCount());
+ assertEquals(9, task.getTotalAttemptCount());
+ assertEquals(HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus(), task.getStatus());
+ ArgumentCaptor