优化
This commit is contained in:
@@ -19,7 +19,7 @@ class FinanceContractTest {
|
||||
assertTrue(sql.contains("currency_code CHECK (currency_code = 'CNY')") || sql.contains("currency_code = 'CNY'"));
|
||||
assertTrue(sql.contains("fin_fee_source") && sql.contains("fin_inbox_event"));
|
||||
assertTrue(sql.contains("fin_bill_revision"));
|
||||
assertTrue(sql.contains("PROCESSING/SUCCEEDED/FAILED"));
|
||||
assertTrue(sql.contains("PROCESSING") && sql.contains("SUCCEEDED") && sql.contains("FAILED"));
|
||||
String service = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinanceService.java"));
|
||||
assertTrue(service.contains("status='PROCESSING'") && service.contains("stableNo"));
|
||||
assertTrue(service.contains("request_hash") && service.contains("result_resource_id") && service.contains("Idempotency-Key已用于其他请求"));
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.finance.controller.FinanceExceptionHandler;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FinanceExceptionHandlerTest {
|
||||
private final FinanceExceptionHandler handler = new FinanceExceptionHandler();
|
||||
|
||||
@Test
|
||||
void returnsStableFinanceConflictCodeAndTraceFields() {
|
||||
var response = handler.business(new BizException(ResultCode.CONFLICT, "幂等键冲突"));
|
||||
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().code()).isEqualTo("FIN_409");
|
||||
assertThat(response.getBody().requestId()).isNotBlank();
|
||||
assertThat(response.getBody().traceId()).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
void dependencyFailureKeepsInfrastructureHttpStatus() {
|
||||
var response = handler.business(new BizException(ResultCode.SERVICE_UNAVAILABLE, "File服务不可用"));
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(503);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().code()).isEqualTo("FIN_503");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem;
|
||||
import com.baiyue.finance.service.FinanceQueryService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FinanceFeePoolDirectionBehaviorTest {
|
||||
@Test
|
||||
void receivablePoolNeverContainsPayableSource() {
|
||||
DirectionJdbc jdbc = new DirectionJdbc();
|
||||
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
|
||||
|
||||
List<FeePoolItem> result = service.feePool(7L, "RECEIVABLE");
|
||||
|
||||
assertThat(result).isNotEmpty().allMatch(item -> "RECEIVABLE".equals(item.direction()));
|
||||
assertThat(jdbc.sourceDirections).containsExactly("RECEIVABLE");
|
||||
assertThat(jdbc.costQueryCount).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void payablePoolNeverContainsReceivableSource() {
|
||||
DirectionJdbc jdbc = new DirectionJdbc();
|
||||
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
|
||||
|
||||
List<FeePoolItem> result = service.feePool(7L, "PAYABLE");
|
||||
|
||||
assertThat(result).isNotEmpty().allMatch(item -> "PAYABLE".equals(item.direction()));
|
||||
assertThat(jdbc.sourceDirections).containsExactly("PAYABLE");
|
||||
assertThat(jdbc.costQueryCount).isEqualTo(1);
|
||||
}
|
||||
|
||||
private static final class DirectionJdbc extends JdbcTemplate {
|
||||
private final List<String> sourceDirections = new ArrayList<>();
|
||||
private int costQueryCount;
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> List<T> query(String sql, RowMapper<T> mapper, Object... args) {
|
||||
if (sql.contains("FROM fin_fee_source")) {
|
||||
String direction = String.valueOf(args[1]);
|
||||
sourceDirections.add(direction);
|
||||
return (List<T>) List.of(item(direction, "FIRSTLEG_FEE", 1L));
|
||||
}
|
||||
if (sql.contains("FROM fin_cost")) {
|
||||
costQueryCount++;
|
||||
return (List<T>) List.of(item("PAYABLE", "MANUAL_COST", 2L));
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private static FeePoolItem item(String direction, String sourceType, long sourceId) {
|
||||
return new FeePoolItem(direction + ":" + sourceId, direction, sourceType, sourceId, 1,
|
||||
11L, 21L, null, "清关费", new BigDecimal("100.00"), "CNY", "AVAILABLE",
|
||||
LocalDateTime.of(2026, 8, 11, 1, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.finance.client.FirstlegFeeSourceClient;
|
||||
import com.baiyue.finance.dto.FinanceFeeSourceEvent;
|
||||
import com.baiyue.finance.service.FinanceEventException;
|
||||
import com.baiyue.finance.service.FinanceFeeSourceEventService;
|
||||
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.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
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 FinanceFeeSourceEventServiceTest {
|
||||
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
private final FirstlegFeeSourceClient firstleg = mock(FirstlegFeeSourceClient.class);
|
||||
private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
|
||||
private final FinanceFeeSourceEventService service = new FinanceFeeSourceEventService(jdbc, firstleg, mapper);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(service, "internalToken", "test-token");
|
||||
}
|
||||
|
||||
@Test
|
||||
void importsApprovedSettlementInboundFee() {
|
||||
FinanceFeeSourceEvent event = inboundEvent("published-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
|
||||
givenNewEvent();
|
||||
when(firstleg.inbound("test-token", 21, 31)).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null)));
|
||||
|
||||
assertThat(service.consume(event)).isEqualTo(101L);
|
||||
verify(firstleg).inbound("test-token", 21, 31);
|
||||
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void importsApprovedSettlementContainerFeeWithCustomerOwnership() {
|
||||
FinanceFeeSourceEvent event = containerEvent("container-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
|
||||
givenNewEvent();
|
||||
when(firstleg.container("test-token", 41, 31)).thenReturn(ApiResponse.success(snapshot("CONTAINER_FEE", 7, 1, null, 41L)));
|
||||
|
||||
assertThat(service.consume(event)).isEqualTo(101L);
|
||||
verify(firstleg).container("test-token", 41, 31);
|
||||
verify(jdbc).update(contains("status='SUPERSEDED'"), eq(31L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adjustmentCreatesNewImmutableVersionAndSupersedesAvailableVersion() {
|
||||
FinanceFeeSourceEvent event = inboundEvent("adjusted-2", "FIRSTLEG_FEE_ADJUSTED", 2, 7);
|
||||
givenNewEvent();
|
||||
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
|
||||
|
||||
assertThat(service.consume(event)).isEqualTo(101L);
|
||||
verify(jdbc).update(contains("status='SUPERSEDED'"), eq(31L));
|
||||
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void voidMarksExistingVersionWithoutDeletingHistory() {
|
||||
FinanceFeeSourceEvent event = inboundEvent("voided-2", "FIRSTLEG_FEE_VOIDED", 2, 7);
|
||||
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("voided-2"))).thenReturn(List.of());
|
||||
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
|
||||
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), eq(2)))
|
||||
.thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE")));
|
||||
|
||||
assertThat(service.consume(event)).isEqualTo(88L);
|
||||
verify(jdbc).update("UPDATE fin_fee_source SET status='VOIDED' WHERE id=?", 88L);
|
||||
verify(jdbc, never()).update(startsWith("DELETE"), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameEventAndContentReturnsFirstResourceWithoutCallingFirstlegAgain() throws Exception {
|
||||
FinanceFeeSourceEvent event = inboundEvent("same-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
|
||||
String requestHash = sha256(mapper.writeValueAsString(event));
|
||||
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("same-1")))
|
||||
.thenReturn(List.of(Map.of("payload_hash", requestHash, "status", "PROCESSED", "result_resource_id", 66L)));
|
||||
|
||||
assertThat(service.consume(event)).isEqualTo(66L);
|
||||
verifyNoInteractions(firstleg);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameEventIdWithDifferentContentReturnsStableConflictCode() {
|
||||
FinanceFeeSourceEvent event = inboundEvent("conflict-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
|
||||
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("conflict-1")))
|
||||
.thenReturn(List.of(Map.of("payload_hash", "different", "status", "PROCESSED", "result_resource_id", 66L)));
|
||||
|
||||
assertThatThrownBy(() -> service.consume(event))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-002");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsSnapshotBelongingToAnotherCustomer() {
|
||||
FinanceFeeSourceEvent event = inboundEvent("tenant-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
|
||||
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("tenant-1"))).thenReturn(List.of());
|
||||
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 8, 1, 21L, null)));
|
||||
|
||||
assertThatThrownBy(() -> service.consume(event))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-007");
|
||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsApprovedButUnpublishedNonVoidedFee() {
|
||||
FinanceFeeSourceEvent event = inboundEvent("draft-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
|
||||
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("draft-1"))).thenReturn(List.of());
|
||||
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(
|
||||
ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null, "DRAFT", new BigDecimal("100.00"))));
|
||||
|
||||
assertThatThrownBy(() -> service.consume(event))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-015");
|
||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsZeroAmount() {
|
||||
assertInvalidAmount("zero-1", BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNegativeAmount() {
|
||||
assertInvalidAmount("negative-1", new BigDecimal("-0.01"));
|
||||
}
|
||||
|
||||
private void givenNewEvent() {
|
||||
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), anyString())).thenReturn(List.of());
|
||||
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), anyInt())).thenReturn(List.of());
|
||||
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), eq(31L), anyInt())).thenReturn(101L);
|
||||
}
|
||||
|
||||
private FinanceFeeSourceEvent inboundEvent(String id, String type, int version, long tenant) {
|
||||
return event(id, type, version, tenant, new FinanceFeeSourceEvent.Payload("21", null, "31", version));
|
||||
}
|
||||
|
||||
private FinanceFeeSourceEvent containerEvent(String id, String type, int version, long tenant) {
|
||||
return event(id, type, version, tenant, new FinanceFeeSourceEvent.Payload(null, "41", "31", version));
|
||||
}
|
||||
|
||||
private FinanceFeeSourceEvent event(String id, String type, int version, long tenant, FinanceFeeSourceEvent.Payload payload) {
|
||||
try {
|
||||
return new FinanceFeeSourceEvent(id, type, 1, "firstleg-service", LocalDateTime.of(2026, 8, 11, 1, 2),
|
||||
String.valueOf(tenant), payload.inboundId() == null ? "CONTAINER" : "INBOUND",
|
||||
payload.inboundId() == null ? payload.containerId() : payload.inboundId(), version,
|
||||
id, sha256(mapper.writeValueAsString(payload)), payload);
|
||||
} catch (Exception error) {
|
||||
throw new AssertionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private FirstlegFeeSourceClient.Snapshot snapshot(String sourceType, long customerId, int version, Long inboundId, Long containerId) {
|
||||
return snapshot(sourceType, customerId, version, inboundId, containerId, "PUBLISHED", new BigDecimal("100.00"));
|
||||
}
|
||||
|
||||
private FirstlegFeeSourceClient.Snapshot snapshot(String sourceType, long customerId, int version,
|
||||
Long inboundId, Long containerId,
|
||||
String publicationStatus, BigDecimal amount) {
|
||||
return new FirstlegFeeSourceClient.Snapshot(customerId, sourceType, 31, version, inboundId,
|
||||
inboundId == null ? null : "IB-21", 51L, containerId, null,
|
||||
containerId == null ? null : "C-41", customerId, "客户", 61L, "CUSTOMS",
|
||||
"清关费", "RECEIVABLE", LocalDate.of(2026, 8, 11), "CNY", amount,
|
||||
"APPROVED", publicationStatus, true, LocalDateTime.of(2026, 8, 11, 1, 0),
|
||||
BigDecimal.ONE, "SHIPMENT", BigDecimal.TEN, BigDecimal.ONE, "source-hash", version);
|
||||
}
|
||||
|
||||
private void assertInvalidAmount(String eventId, BigDecimal amount) {
|
||||
FinanceFeeSourceEvent event = inboundEvent(eventId, "FIRSTLEG_FEE_PUBLISHED", 1, 7);
|
||||
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq(eventId))).thenReturn(List.of());
|
||||
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(
|
||||
ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null, "PUBLISHED", amount)));
|
||||
|
||||
assertThatThrownBy(() -> service.consume(event))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-010");
|
||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
|
||||
}
|
||||
|
||||
private static String sha256(String value) throws Exception {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FinanceFeeSourceMigrationContractTest {
|
||||
@Test
|
||||
void moduleMigrationAddsImmutableSourceAndInboxIdempotencyFields() throws Exception {
|
||||
String sql = Files.readString(Path.of("src/main/resources/db/fee-source-event-migration.sql"), StandardCharsets.UTF_8);
|
||||
assertThat(sql).contains("information_schema.columns", "source_type", "direction", "container_id",
|
||||
"source_hash", "source_snapshot", "payload_hash", "result_resource_id", "error_code");
|
||||
assertThat(sql).contains("AVAILABLE可结算", "SUPERSEDED已被新版本替代", "VOIDED已作废");
|
||||
assertThat(sql).contains("finance_fee_source_assert_schema");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.finance.client.FinanceFileClient;
|
||||
import com.baiyue.finance.client.FirstlegCostSnapshotClient;
|
||||
import com.baiyue.finance.controller.ClientFinanceController;
|
||||
import com.baiyue.finance.controller.PlatformFinanceController;
|
||||
import com.baiyue.finance.dto.FinanceReadModels.ClientReceipt;
|
||||
import com.baiyue.finance.dto.FinanceReadModels.ClientReceiptAllocation;
|
||||
import com.baiyue.finance.service.FinanceQueryService;
|
||||
import com.baiyue.finance.service.FinanceService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FinanceReadApiBehaviorTest {
|
||||
@Mock FinanceService service;
|
||||
@Mock FinanceQueryService queryService;
|
||||
@Mock FinanceFileClient fileClient;
|
||||
@Mock FirstlegCostSnapshotClient snapshotClient;
|
||||
|
||||
@Test
|
||||
void clientReceiptUsesAuthenticatedCustomerAndDoesNotExposeVoucherOrInternalFields() throws Exception {
|
||||
AuthenticatedUser user = new AuthenticatedUser(7L, 23L, null, null, "client", "MERCHANT", "WEB", Set.of());
|
||||
ClientReceipt row = new ClientReceipt(4L, "RCPT-4", new BigDecimal("300.00"), LocalDateTime.of(2026, 8, 11, 10, 0),
|
||||
"BANK_TRANSFER", "CNY", "CONFIRMED", List.of(new ClientReceiptAllocation(8L, "BILL-8", new BigDecimal("100.00"))));
|
||||
when(queryService.clientReceipts(23L)).thenReturn(List.of(row));
|
||||
|
||||
var response = new ClientFinanceController(service, queryService).receipts(user);
|
||||
|
||||
verify(queryService).clientReceipts(23L);
|
||||
String json = new ObjectMapper().findAndRegisterModules().writeValueAsString(response.data());
|
||||
assertThat(json).doesNotContainIgnoringCase("voucher").doesNotContain("createdBy").doesNotContain("updatedBy").doesNotContain("tenantId");
|
||||
assertThat(response.requestId()).isNotBlank();
|
||||
assertThat(response.traceId()).isNotBlank();
|
||||
|
||||
new ClientFinanceController(service, queryService).bill(user, 88L);
|
||||
verify(queryService).statement(23L, 88L, true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformFeePoolPassesExplicitCustomerScopeAndReturnsTraceFields() {
|
||||
var controller = new PlatformFinanceController(service, fileClient, snapshotClient, queryService);
|
||||
|
||||
var response = controller.feePool(31L, "RECEIVABLE");
|
||||
|
||||
verify(queryService).feePool(31L, "RECEIVABLE");
|
||||
assertThat(response.requestId()).isNotBlank();
|
||||
assertThat(response.traceId()).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
void fivePlatformPageMethodsUseTypedQueryFacade() {
|
||||
var controller = new PlatformFinanceController(service, fileClient, snapshotClient, queryService);
|
||||
|
||||
controller.customerAccount(11L);
|
||||
controller.statements(11L);
|
||||
controller.receivableSettlements(11L);
|
||||
controller.payableSettlements(11L);
|
||||
|
||||
verify(queryService).customerAccount(11L);
|
||||
verify(queryService).statements(11L, false);
|
||||
verify(queryService).receivableSettlements(11L, false);
|
||||
verify(queryService).payableSettlements(11L);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package com.baiyue.finance;
|
||||
import com.baiyue.finance.service.FinanceService;
|
||||
import com.baiyue.finance.client.FinanceFileClient;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
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;
|
||||
@@ -71,6 +73,19 @@ class FinanceServiceValidationTest {
|
||||
assertThrows(RuntimeException.class, () -> service.createReceipt(1L, 2L, request, "k3"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void receiptFailsClosedWhenFileValidationCapabilityIsUnavailable() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
|
||||
var request = new FinanceService.ReceiptRequest("R-FILE", 1L, null, "BANK_TRANSFER", null, List.of("77"), null,
|
||||
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE)));
|
||||
|
||||
BizException error = assertThrows(BizException.class, () -> service.createReceipt(9L, 2L, request, "file-down"));
|
||||
|
||||
assertEquals(ResultCode.SERVICE_UNAVAILABLE, error.getResultCode());
|
||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_receipt"), (Object[]) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsVoucherFromAnotherCustomer() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FinanceSqlCommentContractTest {
|
||||
private static final Pattern TABLE = Pattern.compile("CREATE TABLE IF NOT EXISTS (fin_[a-z0-9_]+) ");
|
||||
private static final Pattern COLUMN = Pattern.compile("^\\s{2}([a-z][a-z0-9_]*)\\s+(?:BIGINT|INT|VARCHAR|CHAR|DECIMAL|DATETIME|DATE|JSON|TINYINT)\\b", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern CHINESE = Pattern.compile("[\\p{IsHan}]");
|
||||
|
||||
@Test
|
||||
void everyFinanceBaselineColumnHasMeaningfulChineseComment() throws Exception {
|
||||
List<String> lines = Files.readAllLines(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
|
||||
int tableCount = 0;
|
||||
int columnCount = 0;
|
||||
String currentTable = null;
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (String line : lines) {
|
||||
var table = TABLE.matcher(line);
|
||||
if (table.find()) {
|
||||
currentTable = table.group(1);
|
||||
tableCount++;
|
||||
continue;
|
||||
}
|
||||
if (currentTable == null) continue;
|
||||
if (line.startsWith(") ENGINE=")) {
|
||||
currentTable = null;
|
||||
continue;
|
||||
}
|
||||
var column = COLUMN.matcher(line);
|
||||
if (!column.find()) continue;
|
||||
columnCount++;
|
||||
int comment = line.indexOf("COMMENT '");
|
||||
if (comment < 0 || !CHINESE.matcher(line.substring(comment)).find()) {
|
||||
missing.add(currentTable + "." + column.group(1));
|
||||
}
|
||||
}
|
||||
assertThat(tableCount).isEqualTo(13);
|
||||
assertThat(columnCount).isEqualTo(184);
|
||||
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void keyIsolationStatusSnapshotAndIdempotencyCommentsUseFrozenMeaning() throws Exception {
|
||||
String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
|
||||
assertThat(sql).contains("tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键'");
|
||||
assertThat(sql).contains("AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废");
|
||||
assertThat(sql).contains("RECEIVED已接收、PROCESSED已完成、FAILED处理失败");
|
||||
assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照");
|
||||
assertThat(sql).contains("规范化完整请求的SHA-256十六进制哈希");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.finance.controller.InternalFinanceFeeSourceController;
|
||||
import com.baiyue.finance.dto.FinanceFeeSourceEvent;
|
||||
import com.baiyue.finance.service.FinanceEventException;
|
||||
import com.baiyue.finance.service.FinanceFeeSourceEventService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class InternalFinanceFeeSourceControllerTest {
|
||||
private final FinanceFeeSourceEventService service = mock(FinanceFeeSourceEventService.class);
|
||||
private final InternalFinanceFeeSourceController controller = controller();
|
||||
|
||||
@Test
|
||||
void successfulAcknowledgementAlwaysHasTrackingFields() {
|
||||
FinanceFeeSourceEvent event = mock(FinanceFeeSourceEvent.class);
|
||||
when(service.consume(event)).thenReturn(91L);
|
||||
|
||||
var response = controller.consume("internal-token", event);
|
||||
|
||||
assertThat(response.data()).containsEntry("feeSourceId", 91L);
|
||||
assertThat(response.requestId()).isNotBlank();
|
||||
assertThat(response.traceId()).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingInternalTokenFailsClosedWithStableCode() {
|
||||
assertThatThrownBy(() -> controller.consume(null, mock(FinanceFeeSourceEvent.class)))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-401");
|
||||
}
|
||||
|
||||
private InternalFinanceFeeSourceController controller() {
|
||||
InternalFinanceFeeSourceController value = new InternalFinanceFeeSourceController(service);
|
||||
ReflectionTestUtils.setField(value, "expectedToken", "internal-token");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user