pageList(Page page, WalletRechargeExecCheckDto dto);
+
+ /**
+ * 人工标记差异已处理。
+ *
+ * @param id 差异主键
+ * @param handleRemark 处理备注
+ * @return 仅待处理状态更新成功时返回 {@code true}
+ */
+ Boolean handle(Long id, String handleRemark);
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletReserveExecService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletReserveExecService.java
new file mode 100644
index 000000000..7b223c4f2
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletReserveExecService.java
@@ -0,0 +1,36 @@
+package com.bsd.pjcloud.wallet.service;
+
+/**
+ * 充值/扣款申请预约执行器与对账复核服务。
+ *
+ * 预约单的资金变动只允许从本执行器发生:审批回调只登记待执行状态,
+ * 到期由定时任务逐单 CAS 抢占后按明细幂等执行,配合对账复核保证资金一致性。
+ */
+public interface WalletReserveExecService {
+
+ /**
+ * 扫描并执行到期的预约充值/扣款申请。
+ *
+ * 跨租户扫描「已审批通过 + 预约时间已到 + 待执行/待重试」的申请,
+ * 先复活执行中卡死的申请,再按申请租户恢复上下文逐单执行。
+ *
+ * @return 本轮实际受理(抢占成功)的申请数量
+ */
+ int executeDueReserveApplies();
+
+ /**
+ * 执行单笔预约申请;自动扫描与手动兜底共用同一抢占与幂等逻辑。
+ *
+ * @param id 申请主键
+ * @param manual true 表示手动立即执行:忽略预约时间未到限制,并允许从重试耗尽终态挽回
+ * @return 抢占成功且执行完成的申请返回 {@code true}
+ */
+ boolean executeReserveApply(Long id, boolean manual);
+
+ /**
+ * 复核近期预约申请的明细与钱包流水一致性,差异落对账差异表并自动恢复已修复差异。
+ *
+ * @return 本轮复核的申请数量
+ */
+ int reconcileReserveApplies();
+}
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 3e90a21e5..2ddb0aa7b 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
@@ -154,6 +154,19 @@ 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 68366d4fc..7f4adb750 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
@@ -718,6 +718,24 @@ public class WalletTransactionServiceImpl extends ServiceImpl implements WalletRechargeExecCheckService {
+
+ private final WalletRechargeApplyService walletRechargeApplyService;
+ private final WalletTypeService walletTypeService;
+
+ /**
+ * 分页查询对账差异并按当前页申请批量补齐充值类型与饭卡类型名称。
+ */
+ @Override
+ public Page pageList(Page page, WalletRechargeExecCheckDto dto) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
+ .eq(ObjectUtil.isNotEmpty(dto.getApplyId()), WalletRechargeExecCheckEntity::getApplyId, dto.getApplyId())
+ .eq(ObjectUtil.isNotEmpty(dto.getUserId()), WalletRechargeExecCheckEntity::getUserId, dto.getUserId())
+ .eq(ObjectUtil.isNotEmpty(dto.getDiffType()), WalletRechargeExecCheckEntity::getDiffType, dto.getDiffType())
+ .eq(ObjectUtil.isNotEmpty(dto.getStatus()), WalletRechargeExecCheckEntity::getStatus, dto.getStatus())
+ .between(ObjectUtil.isNotEmpty(dto.getCheckTimeList()),
+ WalletRechargeExecCheckEntity::getCheckTime,
+ ObjectUtil.isNotEmpty(dto.getCheckTimeList()) ? dto.getCheckTimeList()[0] : null,
+ ObjectUtil.isNotEmpty(dto.getCheckTimeList()) ? dto.getCheckTimeList()[1] : null)
+ .orderByDesc(WalletRechargeExecCheckEntity::getCheckTime)
+ .orderByDesc(WalletRechargeExecCheckEntity::getId);
+ Page list = page(page, wrapper);
+ Page result = new Page<>(list.getCurrent(), list.getSize(), list.getTotal());
+ result.setRecords(BeanUtils.toBean(list.getRecords(), WalletRechargeExecCheckVo.class));
+ fillApplyInfo(result.getRecords());
+ return result;
+ }
+
+ /**
+ * 人工标记待处理差异为已处理,重复处理时保持幂等返回失败。
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Boolean handle(Long id, String handleRemark) {
+ WalletRechargeExecCheckEntity detail = getById(id);
+ if (ObjectUtil.isEmpty(detail)) {
+ throw new RuntimeException("对账差异记录不存在");
+ }
+ if (!ObjectUtil.equals(detail.getStatus(), WalletRechargeCheckStatusEnum.PENDING.getCode())) {
+ throw new RuntimeException("仅待处理状态的差异可标记已处理");
+ }
+ WalletRechargeExecCheckEntity updateObj = new WalletRechargeExecCheckEntity();
+ updateObj.setId(id);
+ updateObj.setStatus(WalletRechargeCheckStatusEnum.HANDLED.getCode());
+ updateObj.setHandleTime(LocalDateTime.now());
+ updateObj.setHandleRemark(handleRemark);
+ return updateById(updateObj);
+ }
+
+ /**
+ * 按当前页差异批量补齐申请的充值类型与饭卡类型名称。
+ */
+ private void fillApplyInfo(List records) {
+ if (CollectionUtils.isEmpty(records)) {
+ return;
+ }
+ List applyIds = records.stream().map(WalletRechargeExecCheckVo::getApplyId).distinct().toList();
+ Map applyMap = walletRechargeApplyService.listByIds(applyIds).stream()
+ .collect(Collectors.toMap(WalletRechargeApplyEntity::getId, Function.identity(), (a, b) -> a));
+ List walletTypeIds = applyMap.values().stream()
+ .map(WalletRechargeApplyEntity::getWalletTypeId)
+ .filter(ObjectUtil::isNotEmpty)
+ .distinct().toList();
+ Map walletTypeNameMap = walletTypeIds.isEmpty() ? Map.of()
+ : walletTypeService.listByIds(walletTypeIds).stream()
+ .collect(Collectors.toMap(WalletTypeEntity::getId, WalletTypeEntity::getName, (a, b) -> a));
+ for (WalletRechargeExecCheckVo record : records) {
+ WalletRechargeApplyEntity apply = applyMap.get(record.getApplyId());
+ if (ObjectUtil.isEmpty(apply)) {
+ continue;
+ }
+ record.setBizType(apply.getBizType());
+ record.setWalletTypeName(walletTypeNameMap.get(apply.getWalletTypeId()));
+ }
+ }
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletReserveExecServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletReserveExecServiceImpl.java
new file mode 100644
index 000000000..92107b89a
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletReserveExecServiceImpl.java
@@ -0,0 +1,427 @@
+package com.bsd.pjcloud.wallet.service.impl.recharge;
+
+import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.bsd.pjcloud.common.data.tenant.TenantBroker;
+import com.bsd.pjcloud.flow.task.constant.TaskCheckStatusEnum;
+import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum;
+import com.bsd.pjcloud.wallet.api.constant.WalletBizRechargeEnum;
+import com.bsd.pjcloud.wallet.api.constant.WalletRechargeCheckDiffTypeEnum;
+import com.bsd.pjcloud.wallet.api.constant.WalletRechargeCheckStatusEnum;
+import com.bsd.pjcloud.wallet.api.constant.WalletRechargeInvoiceStatusEnum;
+import com.bsd.pjcloud.wallet.api.constant.WalletReserveExecStatusEnum;
+import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
+import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
+import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity;
+import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeApplyEntity;
+import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeExecCheckEntity;
+import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeImportEntity;
+import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeInvoiceEntity;
+import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargeApplyMapper;
+import com.bsd.pjcloud.wallet.service.WalletManageService;
+import com.bsd.pjcloud.wallet.service.WalletRechargeApplyService;
+import com.bsd.pjcloud.wallet.service.WalletRechargeExecCheckService;
+import com.bsd.pjcloud.wallet.service.WalletRechargeImportService;
+import com.bsd.pjcloud.wallet.service.WalletRechargeInvoiceService;
+import com.bsd.pjcloud.wallet.service.WalletReserveExecService;
+import com.bsd.pjcloud.wallet.service.WalletThirdZyService;
+import com.bsd.pjcloud.wallet.service.WalletTransactionService;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 充值/扣款申请预约执行器与对账复核实现。
+ *
+ * 资金安全设计:预约单的余额变动只从本执行器发生(审批回调只登记状态);
+ * 执行前按 exec_status 条件更新 CAS 抢占,多执行器并发时同一单只有一个赢家;
+ * 每条明细独立事务调用幂等动钱方法(钱包行锁 + 业务号查重),重试、断点续跑、
+ * 卡死复活重跑都不会重复入账;无钱包用户不再静默跳过,而是落对账差异等待人工处理;
+ * 执行成功后立即自检,另有低频对账任务全量复核。
+ */
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class WalletReserveExecServiceImpl implements WalletReserveExecService {
+
+ private static final String TAG = "预约执行器";
+
+ /**
+ * 单轮最多扫描的到期申请数量,控制单轮执行时长与钱包服务压力。
+ */
+ private static final int SCAN_LIMIT = 50;
+
+ /**
+ * 执行中抢占时间超过该分钟数视为执行器卡死,扫描时复活为待重试。
+ */
+ private static final int CLAIM_STUCK_MINUTES = 10;
+
+ /**
+ * 待执行/待重试状态超过该分钟数仍未完成,对账时按执行卡死落差异。
+ */
+ private static final int STUCK_ALERT_MINUTES = 30;
+
+ /**
+ * 对账复核窗口天数,只复核近期执行过或重试耗尽的预约单。
+ */
+ private static final int RECONCILE_DAYS = 7;
+
+ /**
+ * 自动重试次数上限,超过后转终态等待人工处理。
+ */
+ private static final int MAX_EXEC_COUNT = 10;
+
+ /**
+ * 预约执行失败退避分钟数,按尝试次数递增,对齐 10 分钟扫描粒度。
+ */
+ private static final int[] RETRY_BACKOFF_MINUTES = {10, 20, 30};
+
+ private final WalletRechargeApplyMapper applyMapper;
+ private final WalletRechargeApplyService applyService;
+ private final WalletRechargeImportService rechargeImportService;
+ private final WalletManageService walletManageService;
+ private final WalletTransactionService walletTransactionService;
+ private final WalletRechargeInvoiceService walletRechargeInvoiceService;
+ private final WalletRechargeExecCheckService execCheckService;
+ private final WalletThirdZyService walletThirdZyService;
+
+ /**
+ * 跨租户扫描到期申请并逐单按租户执行;单笔失败不影响其余申请。
+ */
+ @Override
+ public int executeDueReserveApplies() {
+ LocalDateTime now = LocalDateTime.now();
+ int revived = applyMapper.reviveStuckReserve(now.minusMinutes(CLAIM_STUCK_MINUTES));
+ if (revived > 0) {
+ log.warn("|{}|复活执行中卡死的预约单,count:{}", TAG, revived);
+ }
+ List dueList = applyMapper.listDueReserveApplies(SCAN_LIMIT);
+ int executed = 0;
+ for (WalletRechargeApplyEntity due : dueList) {
+ if (due.getTenantId() == null) {
+ log.warn("|{}|预约单缺少租户ID,applyId:{}", TAG, due.getId());
+ continue;
+ }
+ try {
+ Boolean ok = TenantBroker.applyAs(due.getTenantId(), tenantId -> executeReserveApply(due.getId(), false));
+ if (Boolean.TRUE.equals(ok)) {
+ executed++;
+ }
+ } catch (Exception exception) {
+ log.error("|{}|预约单执行异常,applyId:{},tenantId:{},error:{}", TAG, due.getId(), due.getTenantId(), exception.getMessage(), exception);
+ }
+ }
+ return executed;
+ }
+
+ /**
+ * 单笔预约申请执行:抢占 → 逐明细幂等动钱 → 收尾;明细各自独立事务,失败退避重试。
+ */
+ @Override
+ public boolean executeReserveApply(Long id, boolean manual) {
+ WalletRechargeApplyEntity detail = applyService.getById(id);
+ if (ObjectUtil.isEmpty(detail) || !ObjectUtil.equals(detail.getReserveFlag(), YesNoEnum.YES.getCode())) {
+ return false;
+ }
+ if (!ObjectUtil.equals(detail.getProcessStatus(), TaskCheckStatusEnum.RESULT.getCode())) {
+ log.warn("|{}|预约单未审批通过,跳过执行,applyId:{},processStatus:{}", TAG, id, detail.getProcessStatus());
+ return false;
+ }
+ if (!claim(detail, manual)) {
+ return false;
+ }
+ try {
+ doExecute(detail);
+ LocalDateTime finishTime = LocalDateTime.now();
+ boolean finalized = applyService.update(new LambdaUpdateWrapper()
+ .set(WalletRechargeApplyEntity::getExecStatus, WalletReserveExecStatusEnum.SUCCESS.getCode())
+ .set(WalletRechargeApplyEntity::getExecTime, finishTime)
+ .set(WalletRechargeApplyEntity::getLastError, null)
+ .eq(WalletRechargeApplyEntity::getId, id)
+ .eq(WalletRechargeApplyEntity::getExecStatus, WalletReserveExecStatusEnum.EXECUTING.getCode()));
+ if (!finalized) {
+ log.error("|{}|预约单收尾状态更新失败,applyId:{},当前状态可能已被并发修改", TAG, id);
+ return false;
+ }
+ syncThirdZy(detail);
+ log.info("|{}|预约单执行成功,applyId:{},manual:{}", TAG, id, manual);
+ selfCheckApply(id);
+ return true;
+ } catch (Exception exception) {
+ log.error("|{}|预约单执行失败,applyId:{},manual:{},error:{}", TAG, id, manual, exception.getMessage(), exception);
+ recordFailure(detail, exception);
+ return false;
+ }
+ }
+
+ /**
+ * 对账复核:近期已执行/重试耗尽的预约单核对明细与流水,长期未完成的按卡死落差异并自动恢复。
+ */
+ @Override
+ public int reconcileReserveApplies() {
+ LocalDateTime now = LocalDateTime.now();
+ List candidates = applyMapper.listReserveReconcileCandidates(
+ now.minusDays(RECONCILE_DAYS), now.minusMinutes(STUCK_ALERT_MINUTES), SCAN_LIMIT);
+ for (WalletRechargeApplyEntity candidate : candidates) {
+ if (candidate.getTenantId() == null) {
+ continue;
+ }
+ try {
+ TenantBroker.runAs(candidate.getTenantId(), tenantId -> reconcileSingle(candidate));
+ } catch (Exception exception) {
+ log.error("|{}|预约单对账异常,applyId:{},tenantId:{},error:{}", TAG, candidate.getId(), candidate.getTenantId(), exception.getMessage(), exception);
+ }
+ }
+ return candidates.size();
+ }
+
+ /**
+ * CAS 抢占:条件更新执行状态为执行中,抢不到说明被并发执行器持有或未到期。
+ */
+ private boolean claim(WalletRechargeApplyEntity detail, boolean manual) {
+ List claimableStatus = manual
+ ? List.of(WalletReserveExecStatusEnum.PENDING.getCode(), WalletReserveExecStatusEnum.RETRY_WAIT.getCode(), WalletReserveExecStatusEnum.FAILED.getCode())
+ : List.of(WalletReserveExecStatusEnum.PENDING.getCode(), WalletReserveExecStatusEnum.RETRY_WAIT.getCode());
+ LambdaUpdateWrapper claimWrapper = new LambdaUpdateWrapper()
+ .set(WalletRechargeApplyEntity::getExecStatus, WalletReserveExecStatusEnum.EXECUTING.getCode())
+ .set(WalletRechargeApplyEntity::getClaimTime, LocalDateTime.now())
+ .eq(WalletRechargeApplyEntity::getId, detail.getId())
+ .in(WalletRechargeApplyEntity::getExecStatus, claimableStatus);
+ if (!manual) {
+ claimWrapper.le(WalletRechargeApplyEntity::getNextExecTime, LocalDateTime.now());
+ }
+ boolean claimed = applyService.update(claimWrapper);
+ if (!claimed) {
+ log.info("|{}|预约单抢占失败,跳过,applyId:{},manual:{}", TAG, detail.getId(), manual);
+ }
+ return claimed;
+ }
+
+ /**
+ * 逐明细幂等动钱:与审批立即执行路径语义一致(发票核对、扣款取负、流水带标签备注),
+ * 但使用幂等方法并给无钱包用户落差异。单条明细独立事务,部分失败可断点续跑。
+ */
+ private void doExecute(WalletRechargeApplyEntity detail) {
+ // 与立即执行路径保持一致:执行时才把绑定发票置为已核对
+ if (ObjectUtil.isNotEmpty(detail.getInvoiceId())) {
+ WalletRechargeInvoiceEntity invoiceUpdate = new WalletRechargeInvoiceEntity();
+ invoiceUpdate.setId(detail.getInvoiceId());
+ invoiceUpdate.setStatus(WalletRechargeInvoiceStatusEnum.CHECKED.getCode());
+ walletRechargeInvoiceService.updateById(invoiceUpdate);
+ }
+ // 与审批路径行为对齐(审批后该快照更新为空操作)
+ applyService.updateBeforePrice(detail);
+
+ List importList = rechargeImportService.list(
+ new LambdaQueryWrapper().eq(WalletRechargeImportEntity::getApplyId, detail.getId()));
+ if (ObjectUtil.isEmpty(importList)) {
+ log.warn("|{}|预约单没有导入明细,直接视为执行完成,applyId:{}", TAG, detail.getId());
+ return;
+ }
+ boolean reset = ObjectUtil.equals(detail.getBizType(), WalletBizRechargeEnum.RESET.getCode());
+ for (WalletRechargeImportEntity item : importList) {
+ Integer price = reset ? item.getPrice()
+ : (WalletBizRechargeEnum.reductionCodes().contains(detail.getBizType()) ? -1 * Math.abs(item.getPrice()) : item.getPrice());
+ WalletManageEntity wallet = walletManageService.getWallet(item.getUserId(), detail.getWalletTypeId());
+ if (ObjectUtil.isEmpty(wallet)) {
+ // 资金安全:无钱包用户不静默跳过,落差异等待人工处理,整单其余明细继续
+ recordDiff(detail.getId(), item.getId(), item.getUserId(), WalletRechargeCheckDiffTypeEnum.NO_WALLET, Math.abs(item.getPrice()), null);
+ continue;
+ }
+ if (reset) {
+ walletTransactionService.resetWalletBalanceIdempotent(wallet.getId(), String.valueOf(item.getId()), item.getTags());
+ } else {
+ WalletBizEnum walletBiz = ObjectUtil.equal(WalletBizRechargeEnum.RETURN_CANTEEN.getCode(), detail.getBizType())
+ ? WalletBizEnum.RETURN_CANTEEN : WalletBizEnum.BATCH;
+ walletTransactionService.addWalletBalanceIdempotent(wallet.getId(), price, String.valueOf(item.getId()), walletBiz, item.getTags(), item.getRemark());
+ }
+ }
+ }
+
+ /**
+ * 失败收尾:累加尝试次数并按退避策略安排下次重试,耗尽转终态等待人工。
+ */
+ private void recordFailure(WalletRechargeApplyEntity detail, Exception exception) {
+ int execCount = (detail.getExecCount() == null ? 0 : detail.getExecCount()) + 1;
+ boolean exhausted = execCount >= MAX_EXEC_COUNT;
+ LocalDateTime nextExecTime = exhausted ? null : LocalDateTime.now().plusMinutes(backoffMinutes(execCount));
+ String lastError = StrUtil.sub(exception.getMessage() == null ? exception.getClass().getSimpleName() : exception.getMessage(), 0, 500);
+ boolean updated = applyService.update(new LambdaUpdateWrapper()
+ .set(WalletRechargeApplyEntity::getExecStatus, exhausted ? WalletReserveExecStatusEnum.FAILED.getCode() : WalletReserveExecStatusEnum.RETRY_WAIT.getCode())
+ .set(WalletRechargeApplyEntity::getExecCount, execCount)
+ .set(WalletRechargeApplyEntity::getNextExecTime, nextExecTime)
+ .set(WalletRechargeApplyEntity::getLastError, lastError)
+ .eq(WalletRechargeApplyEntity::getId, detail.getId())
+ .eq(WalletRechargeApplyEntity::getExecStatus, WalletReserveExecStatusEnum.EXECUTING.getCode()));
+ if (updated) {
+ log.warn("|{}|预约单执行失败已登记,applyId:{},execCount:{},nextExecTime:{},exhausted:{}", TAG, detail.getId(), execCount, nextExecTime, exhausted);
+ }
+ }
+
+ /**
+ * 执行成功后同步正元钱包;失败不影响执行结果,与立即执行路径一致。
+ */
+ private void syncThirdZy(WalletRechargeApplyEntity detail) {
+ try {
+ List importList = rechargeImportService.list(
+ new LambdaQueryWrapper().eq(WalletRechargeImportEntity::getApplyId, detail.getId()));
+ List userIds = importList.stream().map(WalletRechargeImportEntity::getUserId).distinct().toList();
+ if (ObjectUtil.isNotEmpty(userIds)) {
+ walletThirdZyService.syncAllUser(userIds);
+ }
+ } catch (Exception exception) {
+ log.error("|{}|预约执行后同步正元钱包失败,applyId:{},error:{}", TAG, detail.getId(), exception.getMessage());
+ }
+ }
+
+ /**
+ * 对账单笔复核:未完成状态按卡死落差异;已完成状态核对明细与流水并自动恢复已修复差异。
+ */
+ private void reconcileSingle(WalletRechargeApplyEntity candidate) {
+ if (List.of(WalletReserveExecStatusEnum.PENDING.getCode(), WalletReserveExecStatusEnum.EXECUTING.getCode(),
+ WalletReserveExecStatusEnum.RETRY_WAIT.getCode()).contains(candidate.getExecStatus())) {
+ recordDiff(candidate.getId(), 0L, null, WalletRechargeCheckDiffTypeEnum.STUCK, null, null);
+ return;
+ }
+ if (ObjectUtil.equals(candidate.getExecStatus(), WalletReserveExecStatusEnum.FAILED.getCode())) {
+ recordDiff(candidate.getId(), 0L, null, WalletRechargeCheckDiffTypeEnum.RETRY_EXHAUSTED, null, null);
+ }
+ // 已到终态(执行成功或耗尽)后卡死差异可自动恢复
+ autoResolveDiff(candidate.getId(), 0L, WalletRechargeCheckDiffTypeEnum.STUCK.getCode());
+ selfCheckApply(candidate.getId());
+ }
+
+ /**
+ * 自检单笔申请:每条导入明细在钱包流水中应有且仅有一条对应记录且金额一致。
+ */
+ private void selfCheckApply(Long applyId) {
+ try {
+ WalletRechargeApplyEntity detail = applyService.getById(applyId);
+ if (ObjectUtil.isEmpty(detail)) {
+ return;
+ }
+ List importList = rechargeImportService.list(
+ new LambdaQueryWrapper().eq(WalletRechargeImportEntity::getApplyId, applyId));
+ if (ObjectUtil.isEmpty(importList)) {
+ return;
+ }
+ boolean reset = ObjectUtil.equals(detail.getBizType(), WalletBizRechargeEnum.RESET.getCode());
+ String expectBizType = reset ? WalletBizEnum.RESET.getCode()
+ : (ObjectUtil.equal(WalletBizRechargeEnum.RETURN_CANTEEN.getCode(), detail.getBizType())
+ ? WalletBizEnum.RETURN_CANTEEN.getCode() : WalletBizEnum.BATCH.getCode());
+ List bizIds = importList.stream().map(v -> String.valueOf(v.getId())).toList();
+ Map> txGroup = walletTransactionService.list(
+ new LambdaQueryWrapper()
+ .in(WalletTransactionEntity::getBizId, bizIds)
+ .eq(WalletTransactionEntity::getBizType, expectBizType))
+ .stream().collect(Collectors.groupingBy(WalletTransactionEntity::getBizId));
+ List okImportIds = new java.util.ArrayList<>();
+ for (WalletRechargeImportEntity item : importList) {
+ Integer expectPrice = reset ? null
+ : (WalletBizRechargeEnum.reductionCodes().contains(detail.getBizType()) ? -1 * Math.abs(item.getPrice()) : item.getPrice());
+ List txList = txGroup.get(String.valueOf(item.getId()));
+ if (ObjectUtil.isEmpty(txList)) {
+ recordDiff(applyId, item.getId(), item.getUserId(), WalletRechargeCheckDiffTypeEnum.MISSING_TX, expectPrice, null);
+ } else if (txList.size() > 1) {
+ recordDiff(applyId, item.getId(), item.getUserId(), WalletRechargeCheckDiffTypeEnum.DUPLICATE_TX, expectPrice, txList.get(0).getPrice());
+ } else if (expectPrice != null && !ObjectUtil.equals(txList.get(0).getPrice(), expectPrice)) {
+ recordDiff(applyId, item.getId(), item.getUserId(), WalletRechargeCheckDiffTypeEnum.AMOUNT_MISMATCH, expectPrice, txList.get(0).getPrice());
+ } else {
+ okImportIds.add(item.getId());
+ }
+ }
+ // 本次校验通过的明细,历史待处理差异自动恢复
+ for (Long importId : okImportIds) {
+ for (WalletRechargeCheckDiffTypeEnum diffType : List.of(WalletRechargeCheckDiffTypeEnum.MISSING_TX,
+ WalletRechargeCheckDiffTypeEnum.DUPLICATE_TX, WalletRechargeCheckDiffTypeEnum.AMOUNT_MISMATCH)) {
+ autoResolveDiff(applyId, importId, diffType.getCode());
+ }
+ }
+ } catch (Exception exception) {
+ log.error("|{}|预约单自检异常,applyId:{},error:{}", TAG, applyId, exception.getMessage(), exception);
+ }
+ }
+
+ /**
+ * 幂等落差异:同键待处理记录刷新检查时间,已自动恢复的重新置待处理,人工已处理的不翻旧账;
+ * 数据库唯一键兜底并发插入。
+ */
+ private void recordDiff(Long applyId, Long importId, Long userId, WalletRechargeCheckDiffTypeEnum diffType, Integer expectedPrice, Integer actualPrice) {
+ WalletRechargeExecCheckEntity existing = execCheckService.getOne(new LambdaQueryWrapper()
+ .eq(WalletRechargeExecCheckEntity::getApplyId, applyId)
+ .eq(WalletRechargeExecCheckEntity::getImportId, importId)
+ .eq(WalletRechargeExecCheckEntity::getDiffType, diffType.getCode())
+ .last("limit 1"));
+ if (existing != null) {
+ if (ObjectUtil.equals(existing.getStatus(), WalletRechargeCheckStatusEnum.PENDING.getCode())) {
+ WalletRechargeExecCheckEntity refresh = new WalletRechargeExecCheckEntity();
+ refresh.setId(existing.getId());
+ refresh.setCheckTime(LocalDateTime.now());
+ refresh.setActualPrice(actualPrice);
+ execCheckService.updateById(refresh);
+ } else if (ObjectUtil.equals(existing.getStatus(), WalletRechargeCheckStatusEnum.AUTO_RECOVERED.getCode())) {
+ WalletRechargeExecCheckEntity reopen = new WalletRechargeExecCheckEntity();
+ reopen.setId(existing.getId());
+ reopen.setStatus(WalletRechargeCheckStatusEnum.PENDING.getCode());
+ reopen.setCheckTime(LocalDateTime.now());
+ reopen.setActualPrice(actualPrice);
+ execCheckService.updateById(reopen);
+ log.warn("|{}|对账差异重新出现,applyId:{},importId:{},diffType:{}", TAG, applyId, importId, diffType.getCode());
+ }
+ return;
+ }
+ try {
+ WalletRechargeExecCheckEntity insertObj = WalletRechargeExecCheckEntity.builder()
+ .applyId(applyId)
+ .importId(importId)
+ .userId(userId)
+ .diffType(diffType.getCode())
+ .expectedPrice(expectedPrice)
+ .actualPrice(actualPrice)
+ .checkTime(LocalDateTime.now())
+ .status(WalletRechargeCheckStatusEnum.PENDING.getCode())
+ .build();
+ execCheckService.save(insertObj);
+ log.warn("|{}|发现对账差异,applyId:{},importId:{},diffType:{},expected:{},actual:{}", TAG, applyId, importId, diffType.getDesc(), expectedPrice, actualPrice);
+ } catch (DuplicateKeyException exception) {
+ // 并发复核同键差异时唯一键兜底,忽略
+ }
+ }
+
+ /**
+ * 自动恢复:对应差异已不存在(本次校验通过)时,把待处理差异置为已自动恢复。
+ */
+ private void autoResolveDiff(Long applyId, Long importId, String diffType) {
+ execCheckService.update(new LambdaUpdateWrapper()
+ .set(WalletRechargeExecCheckEntity::getStatus, WalletRechargeCheckStatusEnum.AUTO_RECOVERED.getCode())
+ .set(WalletRechargeExecCheckEntity::getHandleTime, LocalDateTime.now())
+ .eq(WalletRechargeExecCheckEntity::getApplyId, applyId)
+ .eq(WalletRechargeExecCheckEntity::getImportId, importId)
+ .eq(WalletRechargeExecCheckEntity::getDiffType, diffType)
+ .eq(WalletRechargeExecCheckEntity::getStatus, WalletRechargeCheckStatusEnum.PENDING.getCode()));
+ }
+
+ /**
+ * 失败退避分钟数:第 1、2 次分别为 10、20 分钟,之后固定 30 分钟。
+ */
+ static int backoffMinutes(int execCount) {
+ if (execCount <= 0) {
+ return RETRY_BACKOFF_MINUTES[0];
+ }
+ if (execCount > RETRY_BACKOFF_MINUTES.length) {
+ return RETRY_BACKOFF_MINUTES[RETRY_BACKOFF_MINUTES.length - 1];
+ }
+ return RETRY_BACKOFF_MINUTES[execCount - 1];
+ }
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeApplyMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeApplyMapper.xml
index 240485a90..a11c67191 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeApplyMapper.xml
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeApplyMapper.xml
@@ -23,6 +23,14 @@
a.tenant_id,
a.apply_time,
a.is_authorization,
+ a.reserve_flag,
+ a.reserve_time,
+ a.exec_status,
+ a.exec_time,
+ a.exec_count,
+ a.next_exec_time,
+ a.last_error,
+ a.claim_time,
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletReserveExecBackoffTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletReserveExecBackoffTest.java
new file mode 100644
index 000000000..6e0824476
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletReserveExecBackoffTest.java
@@ -0,0 +1,31 @@
+package com.bsd.pjcloud.wallet.service.impl.recharge;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * 预约执行失败退避策略测试:退避节奏与 10 分钟扫描粒度对齐,防止重试风暴。
+ */
+class WalletReserveExecBackoffTest {
+
+ /**
+ * 第 1、2 次失败分别退避 10、20 分钟,之后固定 30 分钟。
+ */
+ @Test
+ void backoffMinutesShouldFollowScanAlignedSteps() {
+ assertEquals(10, WalletReserveExecServiceImpl.backoffMinutes(1));
+ assertEquals(20, WalletReserveExecServiceImpl.backoffMinutes(2));
+ assertEquals(30, WalletReserveExecServiceImpl.backoffMinutes(3));
+ assertEquals(30, WalletReserveExecServiceImpl.backoffMinutes(9));
+ }
+
+ /**
+ * 非法尝试次数按最短退避处理,保证永远存在下一次可执行时间。
+ */
+ @Test
+ void backoffMinutesShouldClampInvalidCount() {
+ assertEquals(10, WalletReserveExecServiceImpl.backoffMinutes(0));
+ assertEquals(10, WalletReserveExecServiceImpl.backoffMinutes(-1));
+ }
+}
From 7f9fac355a599be7778b7bb7e8c65558a4c95b8b Mon Sep 17 00:00:00 2001
From: dzc <2388224167@qq.com>
Date: Thu, 24 Sep 2026 14:41:13 +0800
Subject: [PATCH 2/2] 11
---
.../pjcloud/hard/controller/coffee/HardCoffeeController.java | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java
index 7d33512be..552c080a9 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java
@@ -1,5 +1,6 @@
package com.bsd.pjcloud.hard.controller.coffee;
+import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeCardPayDto;
@@ -18,8 +19,10 @@ import org.springframework.web.bind.annotation.*;
/**
* 咖啡机H5匿名支付接口。
* 用户扫描咖啡机二维码打开我方H5支付页,页面依次调用拉单和饭卡支付接口;
- * 匿名安全由厂商订单号随机性+5分钟支付窗口+并发锁保证。
+ * 匿名安全由厂商订单号随机性+2分钟支付窗口+并发锁保证。
+ * 移动端开启接口加密后POST body为{encryption:...}密文,必须配套AES解密注解,否则字段解析为空
*/
+@ApiCryptoAes
@RestController
@RequiredArgsConstructor
@RequestMapping("/hard-coffee")