From abec54182df4a1194179cd01e9632c2c305d38af Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 10 Nov 2026 21:21:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=85=85=E5=80=BC=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sentinel/feign/PjcloudSentinelFeign.java | 5 + .../vo/transfer/ThirdTransferOrderVo.java | 2 + .../ThirdTransferBusinessNotifier.java | 8 +- .../service/ThirdTransferOrderStore.java | 15 +-- .../ThirdAppPlusAliTransferServiceImpl.java | 9 +- .../ThirdTransferBusinessNotifierTest.java | 76 +++++++++++++++ .../service/ThirdTransferOrderStoreTest.java | 94 +++++++++++++++++++ ...hirdAppPlusAliTransferServiceImplTest.java | 20 ++++ 8 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdTransferBusinessNotifierTest.java diff --git a/pjcloud-common/pjcloud-common-sentinel/src/main/java/com/bsd/pjcloud/common/sentinel/feign/PjcloudSentinelFeign.java b/pjcloud-common/pjcloud-common-sentinel/src/main/java/com/bsd/pjcloud/common/sentinel/feign/PjcloudSentinelFeign.java index 677e5161d..5c0c3aeb9 100644 --- a/pjcloud-common/pjcloud-common-sentinel/src/main/java/com/bsd/pjcloud/common/sentinel/feign/PjcloudSentinelFeign.java +++ b/pjcloud-common/pjcloud-common-sentinel/src/main/java/com/bsd/pjcloud/common/sentinel/feign/PjcloudSentinelFeign.java @@ -54,6 +54,7 @@ public final class PjcloudSentinelFeign { return this; } + /** 为声明式与动态Feign客户端创建Sentinel调用处理器,显式声明的降级策略按原注解解析。 */ @Override public Feign internalBuild() { super.invocationHandlerFactory(new InvocationHandlerFactory() { @@ -62,6 +63,10 @@ public final class PjcloudSentinelFeign { // 查找 FeignClient 上的 降级策略 FeignClient feignClient = AnnotationUtils.findAnnotation(target.type(), FeignClient.class); + // FeignClientBuilder的动态通知契约没有@FeignClient,仍使用Sentinel处理器并保留请求拦截链。 + if (feignClient == null) { + return new PjcloudSentinelInvocationHandler(target, dispatch); + } Class fallback = feignClient.fallback(); Class fallbackFactory = feignClient.fallbackFactory(); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/transfer/ThirdTransferOrderVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/transfer/ThirdTransferOrderVo.java index 5e2f4aa6c..cd9af41ae 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/transfer/ThirdTransferOrderVo.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/transfer/ThirdTransferOrderVo.java @@ -143,6 +143,8 @@ public class ThirdTransferOrderVo { private Integer attemptCount; /** 下一次执行时间。 */ private LocalDateTime nextRunTime; + /** 本阶段最近一次安全错误码,与单据最近查单错误分别展示。 */ + private String lastErrorCode; /** 最近一次安全错误摘要。 */ private String lastErrorMessage; } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdTransferBusinessNotifier.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdTransferBusinessNotifier.java index 903b206fc..00010a6a2 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdTransferBusinessNotifier.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdTransferBusinessNotifier.java @@ -6,6 +6,7 @@ import com.bsd.pjcloud.thirdparty.api.feign.ThirdTransferResultReceiver; import com.bsd.pjcloud.thirdparty.dto.transfer.ThirdTransferResultNotifyDto; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdTransferOrderEntity; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.cloud.openfeign.FeignClientBuilder; import org.springframework.context.ApplicationContext; import org.springframework.stereotype.Service; @@ -14,6 +15,7 @@ import java.util.concurrent.ConcurrentHashMap; /** 通过服务端登记的内部服务发现目标发送结果,复用平台Feign上下文、租户及内部调用拦截器。 */ @Service +@Slf4j @RequiredArgsConstructor public class ThirdTransferBusinessNotifier { /** 创建受平台管理的Feign客户端所需的应用上下文。 */ @@ -33,8 +35,12 @@ public class ThirdTransferBusinessNotifier { notification.setErrorCode(order.getLastErrorCode()); notification.setErrorMessage(order.getLastErrorMessage()); R response = receiver.receive(notification); - return response != null && response.isOk() && Boolean.TRUE.equals(response.getData()); + boolean acknowledged = response != null && response.isOk() && Boolean.TRUE.equals(response.getData()); + if (!acknowledged) log.warn("转账结果通知未获确认,转账号={},通知ID={},目标服务={},业务码={}", order.getTransferNo(), order.getNotifyId(), serviceId, response == null ? "EMPTY" : response.getCode()); + return acknowledged; } catch (Exception exception) { + // 只记录定位字段和异常类别,收款人、原始报文及异常消息不进入业务日志。 + log.warn("转账结果通知调用失败,转账号={},通知ID={},目标服务={},类别={}", order.getTransferNo(), order.getNotifyId(), order.getNotifyServiceId(), exception.getClass().getSimpleName()); return false; } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdTransferOrderStore.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdTransferOrderStore.java index c79d35112..dc91fbae9 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdTransferOrderStore.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdTransferOrderStore.java @@ -100,6 +100,9 @@ public class ThirdTransferOrderStore { if (ThirdTransferStatusEnum.isTerminal(order.getStatus())) { return order; } + if (Boolean.TRUE.equals(order.getNeedsAttention())) { + throw new IllegalStateException("原转账存在资金事实冲突,请先核对处理"); + } String previous = order.getStatus(); order.setStatus(ThirdTransferStatusEnum.PROCESSING.name()); if (order.getSubmitStartedTime() == null) { @@ -252,7 +255,7 @@ public class ThirdTransferOrderStore { event(order, "BUSINESS_RECHECK", null, order.getStatus(), "业务方恢复原指令并安排核对", null); } - /** 管理端恢复当前资金或当前结果通知对应的失败任务,操作与日志同事务提交。 */ + /** 管理端唤醒原单适用阶段,包括退避中的RETRY任务;正在执行的租约由Mapper保留,恢复与事件同事务提交。 */ @Transactional(rollbackFor = Exception.class) public boolean retryOrder(Long id, ThirdTransferScope scope) { requireVisible(id, scope); @@ -260,7 +263,7 @@ public class ThirdTransferOrderStore { boolean changed = false; for (ThirdPaymentTaskEntity task : taskMapper.selectList(new LambdaQueryWrapper() .eq(ThirdPaymentTaskEntity::getTransferOrderId, id))) { - if (taskCanResume(order, task) && taskMapper.manualRetry(task.getId(), LocalDateTime.now()) > 0) { + if (taskCanResume(order, task) && taskMapper.rearmByTaskKey(order.getTenantId(), task.getTaskKey(), LocalDateTime.now()) > 0) { changed = true; } } @@ -270,7 +273,7 @@ public class ThirdTransferOrderStore { return changed; } - /** 既有通用任务重试入口复用转账归属与状态约束,避免从任务ID绕过授权。 */ + /** 通用任务入口同样恢复原任务键及退避等待,复用完整归属与资金状态校验,执行中租约保持有效。 */ @Transactional(rollbackFor = Exception.class) public boolean retryTask(Long taskId, ThirdTransferScope scope) { ThirdPaymentTaskEntity task = taskMapper.selectById(taskId); @@ -279,7 +282,7 @@ public class ThirdTransferOrderStore { } requireVisible(task.getTransferOrderId(), scope); ThirdTransferOrderEntity order = lock(task.getTransferOrderId()); - boolean changed = taskCanResume(order, task) && taskMapper.manualRetry(taskId, LocalDateTime.now()) > 0; + boolean changed = taskCanResume(order, task) && taskMapper.rearmByTaskKey(order.getTenantId(), task.getTaskKey(), LocalDateTime.now()) > 0; if (changed) { event(order, "MANUAL_RETRY", null, order.getStatus(), "已恢复指定原转账任务", JSONUtil.createObj().set("taskId", String.valueOf(taskId)).set("taskType", task.getTaskType()).toString()); @@ -358,7 +361,7 @@ public class ThirdTransferOrderStore { } return ThirdPaymentTaskTypeEnum.TRANSFER_QUERY.getCode().equals(task.getTaskType()) || ThirdPaymentTaskTypeEnum.TRANSFER_SUBMIT.getCode().equals(task.getTaskType()) - && !ThirdTransferStatusEnum.isTerminal(order.getStatus()); + && !ThirdTransferStatusEnum.isTerminal(order.getStatus()) && !Boolean.TRUE.equals(order.getNeedsAttention()); } /** 只保留诊断所需白名单字段,收款身份和SDK原始报文不进入持久事件。 */ @@ -367,7 +370,7 @@ public class ThirdTransferOrderStore { .set("transferNo", fact.getTransferNo()).set("appId", fact.getAppId()) .set("channelOrderNo", fact.getChannelOrderNo()).set("payFundOrderNo", fact.getPayFundOrderNo()) .set("amountFen", fact.getAmountFen() == null ? null : fact.getAmountFen().toString()) - .set("paidTime", fact.getPaidTime()).set("errorCode", fact.getErrorCode()) + .set("paidTime", fact.getPaidTime()).set("notFound", fact.isNotFound()).set("errorCode", fact.getErrorCode()) .set("errorMessage", fact.getErrorMessage()).toString(); } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliTransferServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliTransferServiceImpl.java index a958698b7..90787d78d 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliTransferServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliTransferServiceImpl.java @@ -84,6 +84,11 @@ public class ThirdAppPlusAliTransferServiceImpl implements ThirdTransferChannel Map.entry("MEMO_REQUIRED_IN_TRANSFER_ERROR", "大额付款须填写付款理由"), Map.entry("PAYER_PAYEE_CANNOT_SAME", "收付款账户不能相同"), Map.entry("REMARK_HAS_SENSITIVE_WORD", "付款理由未通过渠道文案校验")); + /** 配置拒绝和查无原单只提供恢复步骤,资金状态保留待核实,不能据此创建新的付款指令。 */ + private static final Map RECOVERY_ERRORS = Map.of( + "isv.illegal-client-ip", "请求来源IP未通过支付宝应用校验,请核对应用IP白名单与服务出口IP,处理后恢复原转账任务", + "isv.invalid-signature", "支付宝请求签名校验失败,请核对应用私钥与应用证书是否配套,处理后恢复原转账任务", + "ORDER_NOT_EXIST", "支付宝当前未查到原转账单,请核对原提交任务错误;处理后恢复原任务,系统会先查单再按原号原参数重试"); /** 平台登记应用配置,历史交易按固定AppID精确选择。 */ private final AppPlusPayConfig applications; /** 既有双模式客户端及可信公钥快照。 */ @@ -264,7 +269,7 @@ public class ThirdAppPlusAliTransferServiceImpl implements ThirdTransferChannel private ThirdTransferChannelResult rejected(String code, String subCode, boolean query) { String safe = safeCode(subCode); ThirdTransferChannelResult result = ThirdTransferChannelResult.unknown(safe, errorMessage(safe)); - result.setNotFound(query && "ORDER_NOT_EXIST".equals(subCode)); + result.setNotFound(query && "40004".equals(code) && "ORDER_NOT_EXIST".equals(subCode)); if (!query && "40004".equals(code) && DEFINITE_ERRORS.containsKey(safe)) { result.setStatus(ThirdTransferStatusEnum.FAILED.name()); } @@ -288,6 +293,6 @@ public class ThirdAppPlusAliTransferServiceImpl implements ThirdTransferChannel /** 错误说明使用服务端白名单,避免渠道原始报错携带姓名、账号或请求密钥。 */ private String errorMessage(String code) { - return DEFINITE_ERRORS.getOrDefault(code, "渠道结果待核实,请根据错误码检查原单"); + return RECOVERY_ERRORS.getOrDefault(code, DEFINITE_ERRORS.getOrDefault(code, "渠道结果待核实,请根据错误码检查原单")); } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdTransferBusinessNotifierTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdTransferBusinessNotifierTest.java new file mode 100644 index 000000000..50d8b838f --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdTransferBusinessNotifierTest.java @@ -0,0 +1,76 @@ +package com.bsd.pjcloud.thirdparty.service; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.sentinel.feign.PjcloudSentinelFeign; +import com.bsd.pjcloud.thirdparty.api.feign.ThirdTransferResultReceiver; +import com.bsd.pjcloud.thirdparty.dto.transfer.ThirdTransferResultNotifyDto; +import feign.Client; +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.cloud.openfeign.FeignClientFactory; +import org.springframework.cloud.openfeign.support.SpringMvcContract; +import org.springframework.context.ApplicationContext; + +import java.io.IOException; +import java.lang.reflect.Proxy; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 使用项目真实Sentinel封装验证通知契约兼容性,所有渠道与HTTP调用均使用离线替身。 */ +class ThirdTransferBusinessNotifierTest { + + /** 动态Feign契约没有@FeignClient时也能构造代理,避免通知发送前空指针并持续退避。 */ + @Test + void dynamicReceiverBuildsWithSentinelWithoutFeignAnnotation() { + Client transport = mock(Client.class); + ThirdTransferResultReceiver receiver = PjcloudSentinelFeign.builder().contract(new SpringMvcContract()) + .client(transport).target(ThirdTransferResultReceiver.class, "http://offline-test"); + assertTrue(Proxy.isProxyClass(receiver.getClass())); + verifyNoInteractions(transport); + } + + /** 已声明注解且没有显式降级的客户端继续构造原Sentinel代理。 */ + @Test + void declarativeReceiverKeepsOriginalSentinelProxy() { + Client transport = mock(Client.class); + StaticReceiver receiver = PjcloudSentinelFeign.builder().contract(new SpringMvcContract()) + .client(transport).target(StaticReceiver.class, "http://offline-test"); + assertTrue(Proxy.isProxyClass(receiver.getClass())); + verifyNoInteractions(transport); + } + + /** 原注解指定的降级实例仍由Feign上下文解析,运输失败后继续执行该降级。 */ + @Test + void declarativeFallbackStillRunsOnTransportFailure() { + ApplicationContext context = mock(ApplicationContext.class); + FeignClientFactory factory = mock(FeignClientFactory.class); + when(context.getBean(FeignClientFactory.class)).thenReturn(factory); + when(factory.getInstance("test-result-fallback", ResultFallback.class)).thenReturn(new ResultFallback()); + PjcloudSentinelFeign.Builder builder = PjcloudSentinelFeign.builder(); + builder.setApplicationContext(context); + FallbackReceiver receiver = builder.contract(new SpringMvcContract()).encoder((value, type, template) -> template.body("{}")) + .client((request, options) -> { throw new IOException("offline transport failure"); }) + .target(FallbackReceiver.class, "http://offline-test"); + assertTrue(receiver.receive(new ThirdTransferResultNotifyDto()).getData()); + } + + /** 模拟现有声明式客户端,名称仅用于本测试的Feign上下文。 */ + @FeignClient(name = "offline-test", contextId = "test-result-static") + interface StaticReceiver extends ThirdTransferResultReceiver { + } + + /** 模拟现有配置了显式降级的声明式客户端。 */ + @FeignClient(name = "offline-test", contextId = "test-result-fallback", fallback = ResultFallback.class) + interface FallbackReceiver extends ThirdTransferResultReceiver { + } + + /** 测试专用降级实现,通过明确响应验证原降级分支仍被调用。 */ + public static class ResultFallback implements FallbackReceiver { + /** 测试降级接收返回明确确认,不创建任务或调用真实服务。 */ + @Override + public R receive(ThirdTransferResultNotifyDto notification) { + return R.ok(true); + } + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdTransferOrderStoreTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdTransferOrderStoreTest.java index 4197d2097..f4300c169 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdTransferOrderStoreTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdTransferOrderStoreTest.java @@ -4,10 +4,13 @@ import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdTransferOrderEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdTransferEventEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPaymentTaskEntity; import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskMapper; import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdTransferEventMapper; import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdTransferOrderMapper; import com.bsd.pjcloud.thirdparty.model.transfer.ThirdTransferChannelResult; +import com.bsd.pjcloud.thirdparty.model.transfer.ThirdTransferScope; +import com.bsd.pjcloud.thirdparty.vo.transfer.ThirdTransferOrderVo; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -15,6 +18,7 @@ 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.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -143,6 +147,96 @@ class ThirdTransferOrderStoreTest { verify(orderMapper, never()).update(any(), any()); } + /** 原提交处于退避等待时同时唤醒提交与查单阶段,保持原单号、原任务和累计尝试次数。 */ + @Test + void manualRecoveryWakesOriginalRetryingSubmitAndQuery() { + ThirdTransferOrderEntity order = order("UNKNOWN"); + ThirdTransferScope scope = prepareRecovery(order, task("TRANSFER_SUBMIT", "RETRY"), task("TRANSFER_QUERY", "ALERT")); + when(taskMapper.rearmByTaskKey(eq(9L), anyString(), any())).thenReturn(1); + when(eventMapper.insert(any(ThirdTransferEventEntity.class))).thenReturn(1); + + assertTrue(store.retryOrder(1L, scope)); + verify(taskMapper).rearmByTaskKey(eq(9L), eq("TRANSFER_SUBMIT:1"), any()); + verify(taskMapper).rearmByTaskKey(eq(9L), eq("TRANSFER_QUERY:1"), any()); + verify(taskMapper, never()).manualRetry(any(), any()); + verifyNoInteractions(tasks); + assertEquals("TF-test", order.getTransferNo()); + assertEquals("UNKNOWN", order.getStatus()); + } + + /** 成功及明确失败的资金终态均不能唤醒原提交,防止人工恢复变成新付款。 */ + @Test + void terminalFundsDoNotRearmSubmit() { + for (String status : List.of("SUCCESS", "FAILED")) { + ThirdTransferOrderEntity order = order(status); + ThirdTransferScope scope = prepareRecovery(order, task("TRANSFER_SUBMIT", "FAILED")); + assertFalse(store.retryOrder(1L, scope)); + } + verify(taskMapper, never()).rearmByTaskKey(any(), any(), any()); + verifyNoInteractions(tasks, eventMapper); + } + + /** 资金事实冲突时仅恢复核对任务,原提交在行锁内再次阻止执行。 */ + @Test + void conflictingFundsCannotResumeOrBeginSubmit() { + ThirdTransferOrderEntity order = order("UNKNOWN"); + order.setNeedsAttention(true); + ThirdTransferScope scope = prepareRecovery(order, task("TRANSFER_SUBMIT", "ALERT")); + assertFalse(store.retryOrder(1L, scope)); + assertThrows(IllegalStateException.class, () -> store.beginSubmit(1L)); + verify(taskMapper, never()).rearmByTaskKey(any(), any(), any()); + verify(orderMapper, never()).update(any(), any()); + verifyNoInteractions(tasks, eventMapper); + } + + /** 执行中的任务由条件更新保留活跃租约,界面收到false后提示核对当前执行状态。 */ + @Test + void activeLeaseIsNotForcedIntoAnotherExecution() { + ThirdTransferOrderEntity order = order("UNKNOWN"); + ThirdTransferScope scope = prepareRecovery(order, task("TRANSFER_SUBMIT", "PROCESSING")); + when(taskMapper.rearmByTaskKey(eq(9L), eq("TRANSFER_SUBMIT:1"), any())).thenReturn(0); + assertFalse(store.retryOrder(1L, scope)); + verify(taskMapper, never()).manualRetry(any(), any()); + verifyNoInteractions(tasks, eventMapper); + } + + /** 通用任务页按ID恢复时沿用同一权限与原任务键,退避中的提交也能被明确唤醒。 */ + @Test + void singleTaskRecoveryWakesOriginalRetryingSubmit() { + ThirdTransferOrderEntity order = order("UNKNOWN"); + ThirdPaymentTaskEntity task = task("TRANSFER_SUBMIT", "RETRY"); + ThirdTransferScope scope = new ThirdTransferScope(true, List.of(), null); + when(taskMapper.selectById(10L)).thenReturn(task); + when(orderMapper.scopeById(1L, scope)).thenReturn(new ThirdTransferOrderVo()); + when(orderMapper.selectForUpdate(1L)).thenReturn(order); + when(taskMapper.rearmByTaskKey(eq(9L), eq("TRANSFER_SUBMIT:1"), any())).thenReturn(1); + when(eventMapper.insert(any(ThirdTransferEventEntity.class))).thenReturn(1); + assertTrue(store.retryTask(10L, scope)); + verify(taskMapper, never()).manualRetry(any(), any()); + verifyNoInteractions(tasks); + } + + /** 对同租户已通过完整数据权限的原单模拟锁和任务查询,恢复用例仍调用真实Store规则。 */ + private ThirdTransferScope prepareRecovery(ThirdTransferOrderEntity order, ThirdPaymentTaskEntity... originalTasks) { + ThirdTransferScope scope = new ThirdTransferScope(true, List.of(), null); + when(orderMapper.scopeById(1L, scope)).thenReturn(new ThirdTransferOrderVo()); + when(orderMapper.selectForUpdate(1L)).thenReturn(order); + when(taskMapper.selectList(any())).thenReturn(List.of(originalTasks)); + return scope; + } + + /** 构造已持久化原任务,测试只恢复固定任务键。 */ + private ThirdPaymentTaskEntity task(String type, String status) { + ThirdPaymentTaskEntity task = new ThirdPaymentTaskEntity(); + task.setTenantId(9L); + task.setTransferOrderId(1L); + task.setTaskType(type); + task.setTaskKey(type + ":1"); + task.setStatus(status); + task.setAttemptCount(7); + return task; + } + /** 配置可修改状态更新及事件落库成功,避免测试模拟数据库事务。 */ private void prepareWrite(ThirdTransferOrderEntity order) { when(orderMapper.selectForUpdate(1L)).thenReturn(order); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliTransferServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliTransferServiceImplTest.java index 44e05b548..dc0926fa2 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliTransferServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliTransferServiceImplTest.java @@ -127,6 +127,26 @@ class ThirdAppPlusAliTransferServiceImplTest { assertFalse(service.query(order()).isNotFound()); response.setSubCode("ORDER_NOT_EXIST"); assertTrue(service.query(order()).isNotFound()); + assertTrue(service.query(order()).getErrorMessage().contains("恢复原任务")); + response.setCode("40002"); + assertFalse(service.query(order()).isNotFound()); + } + + /** IP与签名配置拒绝保留待核实并给出恢复步骤,禁止误判付款失败后生成新指令。 */ + @Test + void gatewayRejectionsDescribeRecoveryWithoutInventingFundsFailure() throws Exception { + prepareClient(); + AlipayFundTransUniTransferResponse response = new AlipayFundTransUniTransferResponse(); + response.setCode("40002"); + when(context.execute(any(AlipayFundTransUniTransferRequest.class))).thenReturn(response); + for (String code : List.of("isv.illegal-client-ip", "isv.invalid-signature")) { + response.setSubCode(code); + var result = service.submit(order(), request()); + assertEquals("UNKNOWN", result.getStatus()); + assertFalse(result.isNotFound()); + assertEquals(code, result.getErrorCode()); + assertTrue(result.getErrorMessage().contains("恢复原转账任务")); + } } /** 成功响应超分精度或缺金额时继续核对,不能四舍五入。 */