feat: close overseas wallet and pallet cost flow
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.finance.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
@FeignClient(name = "event-bridge-service", contextId = "financeWalletResultEventBridgeClient",
|
||||
url = "${OVERSEAS_EVENT_BRIDGE_URL:${baiyue.wallet-event.event-bridge-url:}}")
|
||||
public interface WalletResultEventBridgeClient {
|
||||
@PostMapping("/internal/event-bridge/events")
|
||||
ApiResponse<Acknowledgement> receive(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody EventEnvelope envelope);
|
||||
|
||||
record Acknowledgement(String eventId, String result, String receivedAt) { }
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.finance.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.finance.service.FinanceEventException;
|
||||
import com.baiyue.finance.service.WalletCommandEventService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/internal/finance/v1/wallet-command-events")
|
||||
public class InternalWalletCommandEventController {
|
||||
private final WalletCommandEventService service;
|
||||
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<Map<String, String>> consume(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestBody EventEnvelope event) {
|
||||
if (token == null || expectedToken == null || expectedToken.isBlank()
|
||||
|| !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),
|
||||
expectedToken.getBytes(StandardCharsets.UTF_8))) {
|
||||
throw new FinanceEventException("FIN-EVT-401", "内部服务令牌无效");
|
||||
}
|
||||
return FinanceResponses.success(Map.of("eventId", service.consume(event)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventEnvelopeValidator;
|
||||
import com.baiyue.common.event.EventIdentifiers;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.common.event.WalletCommandCompletedV1;
|
||||
import com.baiyue.common.event.WalletCommandRequestedV1;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletFreezeRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WalletCommandEventService {
|
||||
private static final Set<String> OPERATIONS = Set.of("FREEZE", "SETTLE", "RELEASE");
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final FinanceWalletService walletService;
|
||||
private final TransactionTemplate transactions;
|
||||
|
||||
public String consume(EventEnvelope envelope) {
|
||||
WalletCommandRequestedV1 command = validate(envelope);
|
||||
String existing = existingHash(envelope.eventId());
|
||||
if (existing != null && !existing.equals(envelope.payloadHash())) {
|
||||
throw new FinanceEventException("FIN-EVT-002", "同一事件ID对应不同资金指令");
|
||||
}
|
||||
if (existing == null) reserve(envelope);
|
||||
if (resultExists(envelope.eventId())) return envelope.eventId();
|
||||
|
||||
WalletCommandCompletedV1 result;
|
||||
try {
|
||||
WalletHold hold = transactions.execute(status -> execute(command));
|
||||
result = completed(envelope.eventId(), command, "SUCCEEDED", hold.status(), hold.amount(), null, null);
|
||||
} catch (FinanceEventException exception) {
|
||||
result = completed(envelope.eventId(), command, "FAILED", null, command.amount(), exception.code(), exception.getMessage());
|
||||
} catch (RuntimeException exception) {
|
||||
result = completed(envelope.eventId(), command, "FAILED", null, command.amount(), "FIN-WALLET-COMMAND-FAILED", safe(exception.getMessage()));
|
||||
}
|
||||
WalletCommandCompletedV1 finalResult = result;
|
||||
transactions.executeWithoutResult(status -> finalizeResult(envelope, finalResult));
|
||||
return envelope.eventId();
|
||||
}
|
||||
|
||||
private WalletHold execute(WalletCommandRequestedV1 command) {
|
||||
return switch (command.operation()) {
|
||||
case "FREEZE" -> walletService.freeze(command.customerId(), command.idempotencyKey(),
|
||||
new WalletFreezeRequest(command.amount(), command.businessType(), command.businessNo(), command.remark()));
|
||||
case "SETTLE" -> walletService.settle(command.customerId(), command.businessType(), command.businessNo(),
|
||||
command.idempotencyKey(), new WalletHoldActionRequest(command.remark()));
|
||||
case "RELEASE" -> walletService.release(command.customerId(), command.businessType(), command.businessNo(),
|
||||
command.idempotencyKey(), new WalletHoldActionRequest(command.remark()));
|
||||
default -> throw new FinanceEventException("FIN-EVT-001", "资金指令操作无效");
|
||||
};
|
||||
}
|
||||
|
||||
private WalletCommandRequestedV1 validate(EventEnvelope envelope) {
|
||||
try { EventEnvelopeValidator.requireValid(envelope); }
|
||||
catch (RuntimeException exception) { throw new FinanceEventException("FIN-EVT-001", "资金指令信封无效"); }
|
||||
if (!EventTypes.WALLET_COMMAND_REQUESTED.equals(envelope.eventType())
|
||||
|| envelope.eventVersion() != WalletCommandRequestedV1.VERSION
|
||||
|| envelope.sourceRegion() != RegionCode.OVERSEAS || envelope.targetRegion() != RegionCode.CN) {
|
||||
throw new FinanceEventException("FIN-EVT-001", "资金指令类型、版本或区域无效");
|
||||
}
|
||||
try {
|
||||
WalletCommandRequestedV1 value = objectMapper.treeToValue(envelope.payload(), WalletCommandRequestedV1.class);
|
||||
String operation = value.operation() == null ? "" : value.operation().trim().toUpperCase(Locale.ROOT);
|
||||
if (!OPERATIONS.contains(operation) || value.customerId() <= 0 || blank(value.businessType())
|
||||
|| blank(value.businessNo()) || blank(value.idempotencyKey())
|
||||
|| ("FREEZE".equals(operation) && (value.amount() == null || value.amount().signum() <= 0))) {
|
||||
throw new FinanceEventException("FIN-EVT-001", "资金指令字段无效");
|
||||
}
|
||||
if (!String.valueOf(value.customerId()).equals(envelope.tenantId())) {
|
||||
throw new FinanceEventException("FIN-EVT-007", "资金指令客户归属不一致");
|
||||
}
|
||||
return new WalletCommandRequestedV1(operation, value.customerId(), value.businessType(), value.businessNo(),
|
||||
value.amount(), value.remark(), value.idempotencyKey(), value.requestedAt());
|
||||
} catch (FinanceEventException exception) { throw exception; }
|
||||
catch (Exception exception) { throw new FinanceEventException("FIN-EVT-001", "资金指令载荷无法解析"); }
|
||||
}
|
||||
|
||||
private void reserve(EventEnvelope envelope) {
|
||||
try {
|
||||
jdbc.update("INSERT INTO fin_wallet_command_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) VALUES(?,?,?,CAST(? AS JSON),'PROCESSING',NOW(3))",
|
||||
envelope.eventId(), envelope.eventType(), envelope.payloadHash(), json(envelope));
|
||||
} catch (DuplicateKeyException ignored) { }
|
||||
}
|
||||
|
||||
private void finalizeResult(EventEnvelope commandEnvelope, WalletCommandCompletedV1 result) {
|
||||
JsonNode payload = objectMapper.valueToTree(result);
|
||||
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload));
|
||||
EventEnvelope completed = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.WALLET_COMMAND_COMPLETED,
|
||||
WalletCommandCompletedV1.VERSION, Instant.now(), RegionCode.CN, RegionCode.OVERSEAS,
|
||||
String.valueOf(result.customerId()), "WALLET_HOLD", result.businessNo(), 1L, null,
|
||||
commandEnvelope.traceId(), commandEnvelope.correlationId(), commandEnvelope.eventId(), payload, payloadHash).requireValid();
|
||||
jdbc.update("INSERT IGNORE INTO fin_wallet_event_outbox(event_id,event_key,payload_json,status,next_retry_at,created_at,updated_at) VALUES(?,?,CAST(? AS JSON),'PENDING',NOW(3),NOW(3),NOW(3))",
|
||||
completed.eventId(), "WALLET_RESULT:" + commandEnvelope.eventId(), json(completed));
|
||||
jdbc.update("UPDATE fin_wallet_command_inbox SET status=?,result_json=CAST(? AS JSON),processed_at=NOW(3) WHERE event_id=?",
|
||||
result.result(), json(result), commandEnvelope.eventId());
|
||||
}
|
||||
|
||||
private String existingHash(String eventId) {
|
||||
List<String> rows = jdbc.queryForList("SELECT payload_hash FROM fin_wallet_command_inbox WHERE event_id=?", String.class, eventId);
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
private boolean resultExists(String eventId) {
|
||||
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_event_outbox WHERE event_key=?", Integer.class,
|
||||
"WALLET_RESULT:" + eventId);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
private WalletCommandCompletedV1 completed(String eventId, WalletCommandRequestedV1 command, String result,
|
||||
String holdStatus, BigDecimal amount, String errorCode, String errorMessage) {
|
||||
return new WalletCommandCompletedV1(eventId, command.operation(), command.customerId(), command.businessType(),
|
||||
command.businessNo(), result, holdStatus, amount, errorCode, errorMessage, Instant.now());
|
||||
}
|
||||
private String json(Object value) {
|
||||
try { return objectMapper.writeValueAsString(value); }
|
||||
catch (Exception exception) { throw new FinanceEventException("FIN-EVT-001", "资金事件序列化失败"); }
|
||||
}
|
||||
private static boolean blank(String value) { return value == null || value.isBlank(); }
|
||||
private static String safe(String value) {
|
||||
if (value == null || value.isBlank()) return "资金指令执行失败";
|
||||
String normalized = value.replaceAll("[\\r\\n\\t]+", " ");
|
||||
return normalized.length() <= 500 ? normalized : normalized.substring(0, 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.finance.client.WalletResultEventBridgeClient;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class WalletResultOutboxRelay {
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final WalletResultEventBridgeClient client;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
@Value("${baiyue.wallet-event.max-attempts:12}") private int maxAttempts;
|
||||
private final String worker = UUID.randomUUID().toString();
|
||||
|
||||
@Scheduled(fixedDelayString = "${baiyue.wallet-event.fixed-delay-ms:5000}")
|
||||
public void relay() {
|
||||
recoverExpired();
|
||||
for (String eventId : candidates()) deliver(eventId);
|
||||
}
|
||||
|
||||
public void deliver(String eventId) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
int claimed = jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',locked_by=?,locked_at=?,updated_at=? WHERE event_id=? AND status='PENDING' AND next_retry_at<=?",
|
||||
worker, now, now, eventId, now);
|
||||
if (claimed != 1) return;
|
||||
Map<String, Object> row = jdbc.queryForMap("SELECT payload_json,retry_count FROM fin_wallet_event_outbox WHERE event_id=?", eventId);
|
||||
try {
|
||||
EventEnvelope envelope = objectMapper.readValue(String.valueOf(row.get("payload_json")), EventEnvelope.class).requireValid();
|
||||
var response = client.receive(internalToken, envelope);
|
||||
if (response == null || !response.successful() || response.data() == null
|
||||
|| !eventId.equals(response.data().eventId())) throw new IllegalStateException("海外事件桥未确认资金结果事件");
|
||||
jdbc.update("UPDATE fin_wallet_event_outbox SET status='PUBLISHED',published_at=NOW(3),locked_by=NULL,locked_at=NULL,last_error=NULL,updated_at=NOW(3) WHERE event_id=? AND locked_by=?",
|
||||
eventId, worker);
|
||||
} catch (Exception exception) {
|
||||
int retries = ((Number) row.get("retry_count")).intValue() + 1;
|
||||
String status = retries >= maxAttempts ? "DEAD" : "PENDING";
|
||||
long delay = Math.min(1800, 5L << Math.min(retries, 8));
|
||||
jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND locked_by=?",
|
||||
status, retries, now.plusSeconds(delay), safe(exception.getMessage()), eventId, worker);
|
||||
log.warn("资金结果事件投递失败 eventId={}, reason={}", eventId, exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> candidates() {
|
||||
return jdbc.queryForList("SELECT event_id FROM fin_wallet_event_outbox WHERE status='PENDING' AND next_retry_at<=NOW(3) ORDER BY created_at,event_id LIMIT 50", String.class);
|
||||
}
|
||||
private void recoverExpired() {
|
||||
jdbc.update("UPDATE fin_wallet_event_outbox SET status='PENDING',locked_by=NULL,locked_at=NULL,next_retry_at=NOW(3),updated_at=NOW(3) WHERE status='SENDING' AND locked_at<DATE_SUB(NOW(3),INTERVAL 2 MINUTE)");
|
||||
}
|
||||
private static String safe(String value) {
|
||||
String text = value == null ? "unknown" : value.replaceAll("[\\r\\n\\t]+", " ");
|
||||
return text.length() <= 1000 ? text : text.substring(0, 1000);
|
||||
}
|
||||
}
|
||||
@@ -26,3 +26,7 @@ spring:
|
||||
namespace: ${NACOS_NAMESPACE:public}
|
||||
|
||||
management.endpoints.web.exposure.include: health,info,prometheus
|
||||
|
||||
baiyue:
|
||||
wallet-event:
|
||||
event-bridge-url: ${OVERSEAS_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}
|
||||
|
||||
@@ -538,3 +538,35 @@ CREATE TABLE IF NOT EXISTS fin_wallet_hold (
|
||||
CONSTRAINT chk_fin_wallet_hold_amount CHECK (amount > 0 AND actual_frozen_amount >= 0 AND credit_frozen_amount >= 0 AND amount=actual_frozen_amount+credit_frozen_amount),
|
||||
CONSTRAINT chk_fin_wallet_hold_status CHECK (status IN ('FROZEN','SETTLED','RELEASED'))
|
||||
) ENGINE=InnoDB COMMENT='客户资金冻结及结算生命周期';
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fin_wallet_command_inbox (
|
||||
event_id VARCHAR(64) NOT NULL COMMENT '海外资金指令事件ID',
|
||||
event_type VARCHAR(96) NOT NULL COMMENT '事件类型',
|
||||
payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256',
|
||||
payload_json JSON NOT NULL COMMENT '冻结事件信封',
|
||||
status VARCHAR(16) NOT NULL COMMENT 'PROCESSING、SUCCEEDED或FAILED',
|
||||
result_json JSON NULL COMMENT '最终资金执行结果',
|
||||
received_at DATETIME(3) NOT NULL COMMENT '接收时间',
|
||||
processed_at DATETIME(3) NULL COMMENT '处理完成时间',
|
||||
PRIMARY KEY (event_id),
|
||||
KEY idx_fin_wallet_command_status (status,received_at)
|
||||
) ENGINE=InnoDB COMMENT='跨区客户资金指令幂等收件箱';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fin_wallet_event_outbox (
|
||||
event_id VARCHAR(64) NOT NULL COMMENT '资金结果事件ID',
|
||||
event_key VARCHAR(160) NOT NULL COMMENT '业务幂等键',
|
||||
payload_json JSON NOT NULL COMMENT '完整事件信封',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、SENDING、PUBLISHED或DEAD',
|
||||
retry_count INT NOT NULL DEFAULT 0 COMMENT '失败次数',
|
||||
next_retry_at DATETIME(3) NOT NULL COMMENT '下次投递时间',
|
||||
locked_by VARCHAR(128) NULL COMMENT '租约持有者',
|
||||
locked_at DATETIME(3) NULL COMMENT '租约时间',
|
||||
published_at DATETIME(3) NULL COMMENT '投递完成时间',
|
||||
last_error VARCHAR(1000) NULL COMMENT '最后失败摘要',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY (event_id),
|
||||
UNIQUE KEY uk_fin_wallet_event_key (event_key),
|
||||
KEY idx_fin_wallet_event_publish (status,next_retry_at,created_at)
|
||||
) ENGINE=InnoDB COMMENT='国内Finance到海外资金结果可靠发送箱';
|
||||
|
||||
@@ -43,8 +43,8 @@ class FinanceSqlCommentContractTest {
|
||||
missing.add(currentTable + "." + column.group(1));
|
||||
}
|
||||
}
|
||||
assertThat(tableCount).isEqualTo(21);
|
||||
assertThat(columnCount).isEqualTo(330);
|
||||
assertThat(tableCount).isEqualTo(23);
|
||||
assertThat(columnCount).isEqualTo(350);
|
||||
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.common.event.WalletCommandRequestedV1;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold;
|
||||
import com.baiyue.finance.service.FinanceEventException;
|
||||
import com.baiyue.finance.service.FinanceWalletService;
|
||||
import com.baiyue.finance.service.WalletCommandEventService;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.TransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionCallback;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class WalletCommandEventServiceTest {
|
||||
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
|
||||
private final FinanceWalletService wallet = mock(FinanceWalletService.class);
|
||||
private final TransactionTemplate transactions = mock(TransactionTemplate.class);
|
||||
private final WalletCommandEventService service = new WalletCommandEventService(jdbc, mapper, wallet, transactions);
|
||||
|
||||
@BeforeEach
|
||||
void executeTransactionCallbacks() {
|
||||
when(transactions.execute(any())).thenAnswer(invocation -> {
|
||||
TransactionCallback<?> callback = invocation.getArgument(0);
|
||||
return callback.doInTransaction(mock(TransactionStatus.class));
|
||||
});
|
||||
doAnswer(invocation -> {
|
||||
Consumer<TransactionStatus> callback = invocation.getArgument(0);
|
||||
callback.accept(mock(TransactionStatus.class));
|
||||
return null;
|
||||
}).when(transactions).executeWithoutResult(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void freezesWalletAndPublishesCompletedEvent() {
|
||||
EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("88.50"));
|
||||
when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(envelope.eventId())))
|
||||
.thenReturn(List.of());
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0);
|
||||
when(wallet.freeze(eq(31L), eq("freeze-procurement-1"), any())).thenReturn(
|
||||
new WalletHold(9L, 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("88.50"),
|
||||
new BigDecimal("80.00"), new BigDecimal("8.50"), "FROZEN", 71L, null,
|
||||
null, null, null));
|
||||
|
||||
assertThat(service.consume(envelope)).isEqualTo(envelope.eventId());
|
||||
|
||||
verify(wallet).freeze(eq(31L), eq("freeze-procurement-1"), argThat(request ->
|
||||
request.amount().compareTo(new BigDecimal("88.50")) == 0
|
||||
&& request.businessNo().equals("PO-1")));
|
||||
verify(jdbc).update(startsWith("INSERT IGNORE INTO fin_wallet_event_outbox"), any(Object[].class));
|
||||
verify(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="),
|
||||
eq("SUCCEEDED"), contains("\"holdStatus\":\"FROZEN\""), eq(envelope.eventId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCustomerMismatchBeforeTouchingWallet() {
|
||||
EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("10.00"));
|
||||
envelope = new EventEnvelope(envelope.eventId(), envelope.eventType(), envelope.eventVersion(),
|
||||
envelope.occurredAt(), envelope.sourceRegion(), envelope.targetRegion(), "32", envelope.aggregateType(),
|
||||
envelope.aggregateId(), envelope.aggregateVersion(), envelope.handoverVersion(), envelope.traceId(),
|
||||
envelope.correlationId(), envelope.causationId(), envelope.payload(), envelope.payloadHash());
|
||||
|
||||
EventEnvelope mismatched = envelope;
|
||||
assertThatThrownBy(() -> service.consume(mismatched))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-007");
|
||||
verifyNoInteractions(wallet);
|
||||
}
|
||||
|
||||
private EventEnvelope envelope(String operation, long customerId, BigDecimal amount) {
|
||||
WalletCommandRequestedV1 command = new WalletCommandRequestedV1(operation, customerId,
|
||||
"OVERSEAS_PROCUREMENT_FEE", "PO-1", amount, "采购快递费",
|
||||
"freeze-procurement-1", Instant.parse("2026-09-05T01:00:00Z"));
|
||||
JsonNode payload = mapper.valueToTree(command);
|
||||
return new EventEnvelope(UUID.randomUUID().toString(), EventTypes.WALLET_COMMAND_REQUESTED,
|
||||
WalletCommandRequestedV1.VERSION, Instant.parse("2026-09-05T01:00:00Z"),
|
||||
RegionCode.OVERSEAS, RegionCode.CN, String.valueOf(customerId), "WALLET_HOLD", "PO-1", 1,
|
||||
null, UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))).requireValid();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user