优化
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
package com.baiyue.finance.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.outbox.OutboxOperations;
|
||||
import com.baiyue.common.core.outbox.EventOperationsTokenGuard;
|
||||
import com.baiyue.common.event.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.Set;
|
||||
|
||||
/** Internal-only operator recovery. It cannot mutate the original event or call a consumer directly. */
|
||||
@RestController
|
||||
@RequestMapping("/internal/finance/event-operations")
|
||||
public class EventOperationsController {
|
||||
private final EventOperationsTokenGuard guard;
|
||||
private final OutboxOperations operations;
|
||||
|
||||
public EventOperationsController(JdbcTemplate jdbc, PlatformTransactionManager transactions,
|
||||
ObjectMapper mapper, @Value("${baiyue.event-delivery.operations-token:}") String token) {
|
||||
guard = new EventOperationsTokenGuard(token);
|
||||
operations = new OutboxOperations(jdbc, transactions,
|
||||
new OutboxOperations.Layout("fin_wallet_event_outbox", "event_id", "status", Set.of(EventTypes.WALLET_COMMAND_COMPLETED)),
|
||||
json -> {
|
||||
try {
|
||||
EventEnvelope envelope = mapper.readValue(json, EventEnvelope.class).requireValid();
|
||||
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) {
|
||||
throw new IllegalArgumentException("Hash mismatch");
|
||||
}
|
||||
return new OutboxOperations.Identity(envelope.eventId(), envelope.eventType(), envelope.payloadHash());
|
||||
} catch (Exception invalid) {
|
||||
throw new BizException(ResultCode.CONFLICT, "原事件格式或摘要无效,禁止人工重试");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@GetMapping("/{entryId}")
|
||||
public ApiResponse<OutboxOperations.Entry> inspect(
|
||||
@RequestHeader(value = "X-Event-Operations-Token", required = false) String token,
|
||||
@PathVariable String entryId) {
|
||||
guard.require(token);
|
||||
return ApiResponse.success(operations.inspect(entryId));
|
||||
}
|
||||
|
||||
@PostMapping("/{entryId}/retry")
|
||||
public ApiResponse<OutboxOperations.RetryResult> retry(
|
||||
@RequestHeader(value = "X-Event-Operations-Token", required = false) String token,
|
||||
@PathVariable String entryId, @RequestBody OutboxOperations.RetryRequest request) {
|
||||
guard.require(token);
|
||||
return ApiResponse.success(operations.retry(entryId, request));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import com.baiyue.common.event.DeliveryAck;
|
||||
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.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -36,9 +35,8 @@ public class BusinessFeeSourceEventService {
|
||||
throw error("FIN-EVT-001", "跨区费用事件信封无效");
|
||||
}
|
||||
if (!EventTypes.BUSINESS_FEE_SOURCE_CHANGED.equals(envelope.eventType())
|
||||
|| envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.OVERSEAS
|
||||
|| envelope.targetRegion() != RegionCode.CN) {
|
||||
throw error("FIN-EVT-001", "跨区费用事件类型、版本或区域无效");
|
||||
|| envelope.eventVersion() != 1) {
|
||||
throw error("FIN-EVT-001", "跨区费用事件类型或版本无效");
|
||||
}
|
||||
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) {
|
||||
throw new FinanceEventException("FIN-EVT-002", "事件信封摘要与实际载荷不一致");
|
||||
|
||||
@@ -7,7 +7,6 @@ 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;
|
||||
@@ -108,9 +107,8 @@ public class WalletCommandEventService {
|
||||
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", "资金指令类型、版本或区域无效");
|
||||
|| envelope.eventVersion() != WalletCommandRequestedV1.VERSION) {
|
||||
throw new FinanceEventException("FIN-EVT-001", "资金指令类型或版本无效");
|
||||
}
|
||||
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) {
|
||||
throw new FinanceEventException("FIN-EVT-002", "事件信封摘要与实际载荷不一致");
|
||||
@@ -146,7 +144,7 @@ public class WalletCommandEventService {
|
||||
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,
|
||||
WalletCommandCompletedV1.VERSION, Instant.now(),
|
||||
String.valueOf(result.customerId()), "WALLET_HOLD", result.businessNo(), 1L, null,
|
||||
commandEnvelope.traceId(), commandEnvelope.correlationId(), commandEnvelope.eventId(), payload, payloadHash).requireValid();
|
||||
jdbc.update("INSERT 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))",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.core.outbox.EventDeliveryJournal;
|
||||
import com.baiyue.common.core.outbox.EventDeliveryProducer;
|
||||
import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome;
|
||||
import com.baiyue.common.web.EventDeliveryMetrics;
|
||||
import com.baiyue.common.web.EventDeliveryMetrics.Stream;
|
||||
import com.baiyue.common.web.EventDeliveryObservation;
|
||||
import com.baiyue.common.web.EventDeliveryFailure;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.baiyue.common.core.retry.BoundedRetryPolicy;
|
||||
@@ -21,8 +27,10 @@ import java.util.UUID;
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class WalletResultOutboxRelay {
|
||||
public class WalletResultOutboxRelay implements EventDeliveryProducer {
|
||||
private final JdbcTemplate jdbc;
|
||||
private final EventDeliveryJournal journal;
|
||||
private final EventDeliveryMetrics metrics;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final WalletResultLastlegClient client;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
@@ -42,16 +50,21 @@ public class WalletResultOutboxRelay {
|
||||
var policy = retryPolicy();
|
||||
String worker = UUID.randomUUID().toString();
|
||||
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);
|
||||
if (!journal.begin(worker, Stream.FINANCE.service, "WalletCommandCompleted", eventId, eventId,
|
||||
Stream.FINANCE.target("WalletCommandCompleted"), now,
|
||||
() -> 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) == 1)) return;
|
||||
Map<String, Object> row = jdbc.queryForMap("SELECT payload_json,retry_count FROM fin_wallet_event_outbox WHERE event_id=? AND status='SENDING' AND locked_by=?", eventId, worker);
|
||||
int failures = ((Number) row.get("retry_count")).intValue();
|
||||
if (policy.exhausted(failures)) {
|
||||
jdbc.update("UPDATE fin_wallet_event_outbox SET status='DEAD',locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?",
|
||||
"自动投递次数已耗尽(含租约超时)", eventId, worker);
|
||||
journal.complete(worker, Outcome.DEAD, LocalDateTime.now(), "RETRY_EXHAUSTED", null,
|
||||
() -> jdbc.update("UPDATE fin_wallet_event_outbox SET status='DEAD',locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?",
|
||||
"自动投递次数已耗尽(含租约超时)", eventId, worker));
|
||||
return;
|
||||
}
|
||||
Outcome outcome;
|
||||
String diagnostic = null;
|
||||
String auditError = null;
|
||||
boolean requestStarted = false;
|
||||
try {
|
||||
EventEnvelope envelope = objectMapper.readValue(String.valueOf(row.get("payload_json")), EventEnvelope.class).requireValid();
|
||||
@@ -59,21 +72,31 @@ public class WalletResultOutboxRelay {
|
||||
var response = client.receive(internalToken, envelope);
|
||||
EventDeliveryFailure.requireSuccessful(response);
|
||||
response.data().requireMatches(envelope);
|
||||
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 status='SENDING' AND locked_by=?",
|
||||
eventId, worker);
|
||||
outcome = Outcome.valueOf(response.data().outcome().name());
|
||||
} catch (Exception exception) {
|
||||
String error = EventDeliveryFailure.diagnostic(exception, internalToken);
|
||||
diagnostic = EventDeliveryFailure.diagnostic(exception, internalToken);
|
||||
boolean permanent = EventDeliveryFailure.isPermanent(exception)
|
||||
|| (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException));
|
||||
int retries = failures + 1;
|
||||
String status = permanent || policy.exhausted(retries) ? "DEAD" : "PENDING";
|
||||
LocalDateTime next = permanent || policy.exhausted(retries) ? LocalDateTime.now() : LocalDateTime.now().plus(policy.delayAfterFailure(retries));
|
||||
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 status='SENDING' AND locked_by=?",
|
||||
status, retries, next, safe(error), eventId, worker);
|
||||
log.warn("资金结果事件投递失败 eventId={}, reason={}", eventId, error);
|
||||
auditError = EventDeliveryObservation.errorCode(exception, permanent, requestStarted);
|
||||
outcome = permanent || policy.exhausted(failures + 1) ? Outcome.DEAD : Outcome.RETRY;
|
||||
}
|
||||
LocalDateTime finished = LocalDateTime.now();
|
||||
LocalDateTime next = outcome == Outcome.RETRY ? finished.plus(policy.delayAfterFailure(failures + 1)) : null;
|
||||
Outcome result = outcome;
|
||||
String error = diagnostic;
|
||||
journal.complete(worker, result, finished, auditError, next, () -> {
|
||||
if (result == Outcome.RETRY || result == Outcome.DEAD) {
|
||||
return jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=? WHERE event_id=? AND status='SENDING' AND locked_by=?",
|
||||
result == Outcome.DEAD ? "DEAD" : "PENDING", failures + 1, next == null ? finished : next, safe(error), finished, eventId, worker);
|
||||
}
|
||||
return jdbc.update("UPDATE fin_wallet_event_outbox SET status='PUBLISHED',published_at=?,locked_by=NULL,locked_at=NULL,last_error=NULL,updated_at=? WHERE event_id=? AND status='SENDING' AND locked_by=?",
|
||||
finished, finished, eventId, worker);
|
||||
});
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${baiyue.event-delivery.metrics-sample-ms:30000}")
|
||||
public void sampleMetrics() { metrics.sample(Stream.FINANCE, LocalDateTime.now()); }
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -84,10 +107,12 @@ public class WalletResultOutboxRelay {
|
||||
for (Map<String, Object> row : jdbc.queryForList("SELECT event_id,locked_by,retry_count FROM fin_wallet_event_outbox WHERE status='SENDING' AND locked_at<? ORDER BY locked_at,event_id LIMIT 50", cutoff)) {
|
||||
int failures = ((Number) row.get("retry_count")).intValue() + 1;
|
||||
boolean dead = policy.exhausted(failures);
|
||||
jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=? WHERE event_id=? AND status='SENDING' AND locked_by=? AND locked_at<?",
|
||||
dead ? "DEAD" : "PENDING", failures, dead ? now : now.plus(policy.delayAfterFailure(failures)),
|
||||
"投递租约超时,消费结果未知" + (dead ? ";自动次数已耗尽" : ""), now,
|
||||
row.get("event_id"), row.get("locked_by"), cutoff);
|
||||
LocalDateTime next = dead ? null : now.plus(policy.delayAfterFailure(failures));
|
||||
journal.complete((String) row.get("locked_by"), Outcome.LEASE_EXPIRED, now, "LEASE_EXPIRED", next,
|
||||
() -> jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=? WHERE event_id=? AND status='SENDING' AND locked_by=? AND locked_at<?",
|
||||
dead ? "DEAD" : "PENDING", failures, dead ? now : next,
|
||||
"投递租约超时,消费结果未知" + (dead ? ";自动次数已耗尽" : ""), now,
|
||||
row.get("event_id"), row.get("locked_by"), cutoff));
|
||||
}
|
||||
}
|
||||
private BoundedRetryPolicy retryPolicy() {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
spring:
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${FINANCE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_finance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
|
||||
username: ${FINANCE_DB_USERNAME:root}
|
||||
password: ${FINANCE_DB_PASSWORD:123456}
|
||||
|
||||
baiyue:
|
||||
finance:
|
||||
recharge:
|
||||
qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:/customer-recharge-qr.svg}
|
||||
@@ -3,30 +3,29 @@ server:
|
||||
|
||||
spring:
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:dev}
|
||||
active: ${SPRING_PROFILES_ACTIVE:test}
|
||||
application:
|
||||
name: finance-service
|
||||
config:
|
||||
import:
|
||||
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
|
||||
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
|
||||
- nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
|
||||
- nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
|
||||
cloud:
|
||||
nacos:
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
username: ${NACOS_USERNAME:nacos}
|
||||
password: ${NACOS_PASSWORD:nacos}
|
||||
server-addr: ${NACOS_SERVER_ADDR}
|
||||
username: ${NACOS_USERNAME}
|
||||
password: ${NACOS_PASSWORD}
|
||||
discovery:
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
server-addr: ${NACOS_SERVER_ADDR}
|
||||
enabled: ${NACOS_DISCOVERY_ENABLED:true}
|
||||
register-enabled: ${NACOS_REGISTER_ENABLED:true}
|
||||
group: ${NACOS_GROUP:BAIYUE}
|
||||
namespace: ${NACOS_NAMESPACE:public}
|
||||
namespace: ${NACOS_NAMESPACE}
|
||||
config:
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
namespace: ${NACOS_NAMESPACE:public}
|
||||
server-addr: ${NACOS_SERVER_ADDR}
|
||||
namespace: ${NACOS_NAMESPACE}
|
||||
|
||||
management.endpoints.web.exposure.include: health,info,prometheus
|
||||
|
||||
baiyue:
|
||||
wallet-event:
|
||||
event-bridge-url: ${OVERSEAS_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}
|
||||
|
||||
@@ -572,3 +572,38 @@ CREATE TABLE IF NOT EXISTS fin_wallet_event_outbox (
|
||||
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到海外资金结果可靠发送箱';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_delivery_retry_audit (
|
||||
request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键',
|
||||
producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名',
|
||||
entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键',
|
||||
event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识',
|
||||
event_type VARCHAR(64) NOT NULL COMMENT '原事件类型',
|
||||
payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要',
|
||||
operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识',
|
||||
reason VARCHAR(500) NOT NULL COMMENT '人工重试原因',
|
||||
previous_failures INT NOT NULL COMMENT '重试前累计失败次数',
|
||||
previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误',
|
||||
requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间',
|
||||
PRIMARY KEY (request_id),
|
||||
KEY idx_event_retry_entry (producer_table,entry_id,requested_at),
|
||||
KEY idx_event_retry_event (event_id,requested_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_delivery_attempt (
|
||||
attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键',
|
||||
service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务',
|
||||
event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型',
|
||||
entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键',
|
||||
event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空',
|
||||
target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务',
|
||||
started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间',
|
||||
finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间',
|
||||
duration_ms BIGINT NULL COMMENT '尝试耗时毫秒',
|
||||
outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED',
|
||||
error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌',
|
||||
next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间',
|
||||
PRIMARY KEY (attempt_id),
|
||||
KEY idx_delivery_attempt_entry (service_name,entry_id,started_at),
|
||||
KEY idx_delivery_attempt_outcome (outcome,started_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计';
|
||||
|
||||
@@ -5,7 +5,6 @@ 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.finance.service.BusinessFeeSourceEventService;
|
||||
import com.baiyue.finance.service.FinanceSupplierValidator;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
@@ -84,8 +83,7 @@ class BusinessFeeSourceEventServiceTest {
|
||||
JsonNode node = json.valueToTree(payload);
|
||||
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||
return new EventEnvelope("123e4567-e89b-12d3-a456-426614174701",
|
||||
EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.parse("2026-09-02T08:00:00Z"),
|
||||
RegionCode.OVERSEAS, RegionCode.CN, "10001", "BUSINESS_FEE", "701", 1,
|
||||
EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.parse("2026-09-02T08:00:00Z"), "10001", "BUSINESS_FEE", "701", 1,
|
||||
null, "123e4567-e89b-12d3-a456-426614174702", "LASTLEG:FEE:701:1", null,
|
||||
node, payloadHash);
|
||||
}
|
||||
@@ -162,8 +160,7 @@ class BusinessFeeSourceEventServiceTest {
|
||||
payload.putNull("sourceHash");
|
||||
payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
payload.put("action", action);
|
||||
return new EventEnvelope(base.eventId(), base.eventType(), 1, base.occurredAt(),
|
||||
RegionCode.OVERSEAS, RegionCode.CN, "10001", "BUSINESS_FEE", "701", version,
|
||||
return new EventEnvelope(base.eventId(), base.eventType(), 1, base.occurredAt(), "10001", "BUSINESS_FEE", "701", version,
|
||||
null, base.traceId(), "LASTLEG:FEE:701:" + version, null, payload,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ 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;
|
||||
@@ -44,7 +43,7 @@ class WalletCommandEventServiceTest {
|
||||
void rejectsTamperedEnvelopeBeforeOpeningWalletTransaction() {
|
||||
EventEnvelope valid = envelope("FREEZE", 1L, BigDecimal.TEN);
|
||||
EventEnvelope tampered = new EventEnvelope(valid.eventId(), valid.eventType(), valid.eventVersion(),
|
||||
valid.occurredAt(), valid.sourceRegion(), valid.targetRegion(), valid.tenantId(), valid.aggregateType(),
|
||||
valid.occurredAt(), valid.tenantId(), valid.aggregateType(),
|
||||
valid.aggregateId(), valid.aggregateVersion(), valid.handoverVersion(), valid.traceId(),
|
||||
valid.correlationId(), valid.causationId(), valid.payload(), "sha256:" + "0".repeat(64));
|
||||
assertThatThrownBy(() -> service.consume(tampered)).isInstanceOf(FinanceEventException.class)
|
||||
@@ -122,7 +121,7 @@ class WalletCommandEventServiceTest {
|
||||
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.occurredAt(), "32", envelope.aggregateType(),
|
||||
envelope.aggregateId(), envelope.aggregateVersion(), envelope.handoverVersion(), envelope.traceId(),
|
||||
envelope.correlationId(), envelope.causationId(), envelope.payload(), envelope.payloadHash());
|
||||
|
||||
@@ -139,8 +138,7 @@ class WalletCommandEventServiceTest {
|
||||
"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,
|
||||
WalletCommandRequestedV1.VERSION, Instant.parse("2026-09-05T01:00:00Z"), String.valueOf(customerId), "WALLET_HOLD", "PO-1", 1,
|
||||
null, UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))).requireValid();
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ class WalletCommandTransactionTest {
|
||||
new BigDecimal(amount), "fee", "freeze-1", Instant.parse("2026-09-05T01:00:00Z"));
|
||||
var payload = mapper.valueToTree(command);
|
||||
return new EventEnvelope(id, EventTypes.WALLET_COMMAND_REQUESTED, WalletCommandRequestedV1.VERSION,
|
||||
Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, "31", "WALLET_HOLD", "PO-1", 1,
|
||||
Instant.now(), "31", "WALLET_HOLD", "PO-1", 1,
|
||||
null, UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
}
|
||||
|
||||
@@ -8,9 +8,14 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import com.baiyue.common.core.outbox.EventDeliveryJournal;
|
||||
import com.baiyue.common.web.EventDeliveryMetrics;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
@@ -23,15 +28,16 @@ class WalletResultOutboxLeaseTest {
|
||||
var jdbc = new JdbcTemplate(new DriverManagerDataSource(
|
||||
"jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""));
|
||||
jdbc.execute("CREATE TABLE fin_wallet_event_outbox(event_id VARCHAR(64) PRIMARY KEY, payload_json TEXT, status VARCHAR(32), retry_count INT DEFAULT 0, next_retry_at TIMESTAMP NOT NULL, locked_by VARCHAR(128), locked_at TIMESTAMP, updated_at TIMESTAMP, published_at TIMESTAMP, last_error VARCHAR(1000))");
|
||||
jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))");
|
||||
var mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
var payload = mapper.createObjectNode().put("result", "SUCCEEDED");
|
||||
var event = new EventEnvelope(UUID.randomUUID().toString(), EventTypes.WALLET_COMMAND_COMPLETED, 1,
|
||||
Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, "1", "WALLET_HOLD", "ORDER-1", 1L, null,
|
||||
Instant.now(), "1", "WALLET_HOLD", "ORDER-1", 1L, null,
|
||||
"trace", "correlation", null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
jdbc.update("INSERT INTO fin_wallet_event_outbox(event_id,payload_json,status,next_retry_at) VALUES(?,?,'PENDING',DATEADD('SECOND',-5,CURRENT_TIMESTAMP))",
|
||||
event.eventId(), mapper.writeValueAsString(event));
|
||||
var client = mock(WalletResultLastlegClient.class);
|
||||
var relay = new WalletResultOutboxRelay(jdbc, mapper, client);
|
||||
var relay = new WalletResultOutboxRelay(jdbc, new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(jdbc.getDataSource())), new EventDeliveryMetrics(jdbc, new SimpleMeterRegistry()), mapper, client);
|
||||
ReflectionTestUtils.setField(relay, "internalToken", "token");
|
||||
ReflectionTestUtils.setField(relay, "maxAttempts", 12);
|
||||
var firstEntered = new CountDownLatch(1);
|
||||
@@ -54,8 +60,12 @@ class WalletResultOutboxLeaseTest {
|
||||
Future<?> first = pool.submit(() -> relay.deliver(event.eventId()));
|
||||
assertThat(firstEntered.await(10, TimeUnit.SECONDS)).isTrue();
|
||||
String oldOwner = jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class);
|
||||
// Simulate lease recovery while the original HTTP request has not returned.
|
||||
jdbc.update("UPDATE fin_wallet_event_outbox SET status='PENDING',locked_by=NULL,locked_at=NULL");
|
||||
// The actual recovery path must finish the old audit and defer the new attempt.
|
||||
jdbc.update("UPDATE fin_wallet_event_outbox SET locked_at=?", LocalDateTime.now().minusSeconds(61));
|
||||
ReflectionTestUtils.invokeMethod(relay, "recoverExpired");
|
||||
assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id=?", String.class, oldOwner)).isEqualTo("LEASE_EXPIRED");
|
||||
assertThat(jdbc.queryForObject("SELECT next_retry_at FROM fin_wallet_event_outbox", LocalDateTime.class)).isAfter(LocalDateTime.now());
|
||||
jdbc.update("UPDATE fin_wallet_event_outbox SET next_retry_at=?", LocalDateTime.now().minusSeconds(1));
|
||||
Future<?> second = pool.submit(() -> relay.deliver(event.eventId()));
|
||||
assertThat(secondEntered.await(10, TimeUnit.SECONDS)).isTrue();
|
||||
String newOwner = jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class);
|
||||
@@ -64,10 +74,12 @@ class WalletResultOutboxLeaseTest {
|
||||
first.get(10, TimeUnit.SECONDS);
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class)).isEqualTo("SENDING");
|
||||
assertThat(jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class)).isEqualTo(newOwner);
|
||||
assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isZero();
|
||||
assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(1);
|
||||
releaseSecond.countDown();
|
||||
second.get(10, TimeUnit.SECONDS);
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class)).isEqualTo("PUBLISHED");
|
||||
assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id=?", String.class, newOwner)).isEqualTo("APPLIED");
|
||||
assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id=?", String.class, oldOwner)).isEqualTo("LEASE_EXPIRED");
|
||||
} finally {
|
||||
releaseFirst.countDown(); releaseSecond.countDown(); pool.shutdownNow();
|
||||
}
|
||||
|
||||
@@ -8,12 +8,16 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import com.baiyue.common.core.outbox.EventDeliveryJournal;
|
||||
import com.baiyue.common.web.EventDeliveryMetrics;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@@ -28,17 +32,18 @@ class WalletResultRetryTest {
|
||||
jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID()
|
||||
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""));
|
||||
jdbc.execute("CREATE TABLE fin_wallet_event_outbox(event_id VARCHAR(64) PRIMARY KEY, payload_json TEXT, status VARCHAR(32), retry_count INT DEFAULT 0, next_retry_at TIMESTAMP NOT NULL, locked_by VARCHAR(128), locked_at TIMESTAMP, updated_at TIMESTAMP, published_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_error VARCHAR(1000))");
|
||||
jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))");
|
||||
var mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
var payload = mapper.createObjectNode().put("result", "SUCCEEDED");
|
||||
eventId = UUID.randomUUID().toString();
|
||||
var event = new EventEnvelope(eventId, EventTypes.WALLET_COMMAND_COMPLETED, 1, Instant.now(),
|
||||
RegionCode.CN, RegionCode.OVERSEAS, "1", "WALLET_HOLD", "ORDER-1", 1L, null,
|
||||
"1", "WALLET_HOLD", "ORDER-1", 1L, null,
|
||||
"trace", "correlation", null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
originalJson = mapper.writeValueAsString(event);
|
||||
jdbc.update("INSERT INTO fin_wallet_event_outbox(event_id,payload_json,status,next_retry_at) VALUES(?,?,'PENDING',?)",
|
||||
eventId, originalJson, LocalDateTime.now().minusSeconds(1));
|
||||
client = mock(WalletResultLastlegClient.class);
|
||||
relay = new WalletResultOutboxRelay(jdbc, mapper, client);
|
||||
relay = new WalletResultOutboxRelay(jdbc, new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(jdbc.getDataSource())), new EventDeliveryMetrics(jdbc, new SimpleMeterRegistry()), mapper, client);
|
||||
ReflectionTestUtils.setField(relay, "internalToken", "token");
|
||||
ReflectionTestUtils.setField(relay, "maxAttempts", 3);
|
||||
ReflectionTestUtils.setField(relay, "retryJitter", 0.0);
|
||||
@@ -66,6 +71,7 @@ class WalletResultRetryTest {
|
||||
@Test void expiredLastAttemptGoesDeadWithoutAnotherHttpRequest() {
|
||||
jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',retry_count=2,locked_by='expired',locked_at=?",
|
||||
LocalDateTime.now().minusSeconds(61));
|
||||
jdbc.update("INSERT INTO event_delivery_attempt(attempt_id,service_name,event_type,entry_id,event_id,target_name,started_at,outcome) VALUES('expired','finance','WalletCommandCompleted',?,?,'lastleg-events',?,'STARTED')", eventId, eventId, LocalDateTime.now().minusSeconds(61));
|
||||
relay.relay();
|
||||
assertThat(status()).isEqualTo("DEAD");
|
||||
assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(3);
|
||||
@@ -75,6 +81,7 @@ class WalletResultRetryTest {
|
||||
@Test void expiredLeaseCountsAsFailureAndWaitsBeforeRetrying() {
|
||||
jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',locked_by='expired',locked_at=?",
|
||||
LocalDateTime.now().minusSeconds(61));
|
||||
jdbc.update("INSERT INTO event_delivery_attempt(attempt_id,service_name,event_type,entry_id,event_id,target_name,started_at,outcome) VALUES('expired','finance','WalletCommandCompleted',?,?,'lastleg-events',?,'STARTED')", eventId, eventId, LocalDateTime.now().minusSeconds(61));
|
||||
relay.relay();
|
||||
assertThat(status()).isEqualTo("PENDING");
|
||||
assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(1);
|
||||
@@ -82,5 +89,27 @@ class WalletResultRetryTest {
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
@Test void auditStartFailurePreventsHttpAndRollsBackClaim() {
|
||||
jdbc.execute("DROP TABLE event_delivery_attempt");
|
||||
assertThatThrownBy(() -> relay.deliver(eventId)).isInstanceOf(RuntimeException.class);
|
||||
assertThat(status()).isEqualTo("PENDING");
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
@Test void successfulRemoteResponseWithFailedAuditCommitLeavesStartedLeaseForRecovery() throws Exception {
|
||||
var mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
var event = mapper.readValue(originalJson, EventEnvelope.class);
|
||||
when(client.receive(anyString(), any())).thenAnswer(invocation -> {
|
||||
assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt", String.class)).isEqualTo("STARTED");
|
||||
return com.baiyue.common.core.api.ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED));
|
||||
});
|
||||
jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')");
|
||||
assertThatThrownBy(() -> relay.deliver(eventId)).isInstanceOf(RuntimeException.class);
|
||||
assertThat(status()).isEqualTo("SENDING");
|
||||
assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isZero();
|
||||
assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt", String.class)).isEqualTo("STARTED");
|
||||
verify(client).receive(anyString(), any());
|
||||
}
|
||||
|
||||
private String status() { return jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class); }
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.finance.controller;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class EventOperationsControllerTest {
|
||||
@Test void credentialFailureCannotReadOrMutateDatabase() {
|
||||
var jdbc = mock(JdbcTemplate.class);
|
||||
var transactions = mock(PlatformTransactionManager.class);
|
||||
for (String configured : new String[] {"", "configured-operations-secret-32-bytes"}) {
|
||||
var controller = new EventOperationsController(jdbc, transactions, new ObjectMapper(), configured);
|
||||
assertThatThrownBy(() -> controller.inspect(null, "1")).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> controller.retry("wrong", "1", null)).isInstanceOf(BizException.class);
|
||||
}
|
||||
verifyNoInteractions(jdbc, transactions);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user