feat:充值错误处理

This commit is contained in:
dzc committed 2026-11-10 21:21:12 +08:00
1 parent b74b57b962
commit abec54182d
8 files changed
+220 -9

No files matched your search

@@ -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();
@@ -143,6 +143,8 @@ public class ThirdTransferOrderVo {
private Integer attemptCount;
/** 下一次执行时间。 */
private LocalDateTime nextRunTime;
/** 本阶段最近一次安全错误码,与单据最近查单错误分别展示。 */
private String lastErrorCode;
/** 最近一次安全错误摘要。 */
private String lastErrorMessage;
}
@@ -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<Boolean> 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;
}
}
@@ -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<ThirdPaymentTaskEntity>()
.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();
}
@@ -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<String, String> 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, "渠道结果待核实,请根据错误码检查原单"));
}
}
@@ -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<Boolean> receive(ThirdTransferResultNotifyDto notification) {
return R.ok(true);
}
}
}
@@ -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);
@@ -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("恢复原转账任务"));
}
}
/** 成功响应超分精度或缺金额时继续核对,不能四舍五入。 */