This commit is contained in:
2026-08-20 09:43:31 +08:00
parent f25e270f22
commit 7cf4a8d562
25 changed files with 804 additions and 41 deletions
@@ -4,6 +4,8 @@ import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.RequestParam;
@@ -24,6 +26,20 @@ public interface FirstlegFeeSourceClient {
@PathVariable long containerId, @PathVariable long feeId,
@RequestParam int sourceVersion);
@PostMapping("/fee-sources/commands/void")
ApiResponse<CommandResult> voidSource(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestHeader("X-Request-Hash") String requestHash,
@RequestBody VoidCommand request);
record VoidCommand(String sourceType, long sourceFeeId, int sourceVersion,
long customerId, Long inboundId, Long containerId,
long operatorId, String operatorName, String reason) {}
record CommandResult(String sourceType, long sourceFeeId, int sourceVersion,
String status, boolean replayed) {}
record Snapshot(long tenantId, String sourceType, long feeId, int feeVersion,
Long inboundId, String inboundCode, Long orderId, Long containerId,
Long containerBookingId, String containerNo, long customerId,
@@ -121,6 +121,7 @@ public class FinancePhase1OperationController {
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody FeeSourceActionRequest request) {
return FinanceResponses.success(service.voidFinanceFeeSource(feeSourceId, user.userId(), key, request));
return FinanceResponses.success(service.voidFinanceFeeSource(
feeSourceId, user.userId(), user.username(), key, request));
}
}
@@ -72,7 +72,7 @@ public class FinanceFeeSourceEventService {
}
long id = ((Number) row.get("id")).longValue();
if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) {
jdbc.update("UPDATE fin_fee_source SET status='VOIDED' WHERE id=?", id);
jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND status<>'VOIDED'", id);
}
return id;
}
@@ -9,8 +9,12 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
import com.baiyue.finance.client.FirstlegFeeSourceClient;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.slf4j.MDC;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@@ -36,10 +40,21 @@ public class FinancePhase1OperationService {
private static final String CUSTOMER_IDS = "SELECT tenant_id customer_id FROM fin_customer_settlement_profile UNION SELECT tenant_id FROM fin_bill UNION SELECT customer_id FROM fin_fee_source UNION SELECT tenant_id FROM fin_cost";
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FirstlegFeeSourceClient firstleg;
private final String internalToken;
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
@Autowired
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper,
FirstlegFeeSourceClient firstleg,
@Value("${baiyue.security.internal-service-token}") String internalToken) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.firstleg = firstleg;
this.internalToken = internalToken;
}
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this(jdbc, objectMapper, null, "");
}
public PageEnvelope<AccountSummary> listDerivedCustomerAccounts(int page, int pageSize, String customerName, String status) {
@@ -164,6 +179,9 @@ public class FinancePhase1OperationService {
throw error("FIN-REQ-400", adjustment ? "费用调整请求无效" : "费用编辑请求无效");
}
FeeSourceDetail before = getFinanceFeeSource(feeSourceId);
if ("FIRSTLEG".equals(before.sourceSystem())) {
throw error("FIN-FEE-409", "Firstleg费用必须在入库或柜次费用明细中调整,Finance不可直接修改业务金额");
}
String operation = adjustment ? "FEE_SOURCE_ADJUST" : "FEE_SOURCE_EDIT";
String path = "/api/platform/finance/v1/fee-sources/" + feeSourceId + (adjustment ? "/adjust" : "");
Long replay = reserve(before.customerId(), operatorId, operation, key,
@@ -191,10 +209,45 @@ public class FinancePhase1OperationService {
}
@Transactional
public FeeSourceDetail voidFinanceFeeSource(long feeSourceId, long operatorId, String key,
public FeeSourceDetail voidFinanceFeeSource(long feeSourceId, long operatorId,
String operatorName, String key,
FeeSourceActionRequest request) {
return transitionFeeSource(feeSourceId, operatorId, key, request, "FEE_SOURCE_VOID",
"VOIDED", List.of("DRAFT", "PENDING", "REJECTED", "APPROVED"), true);
if (request == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.reason())) {
throw error("FIN-REQ-400", "费用操作请求无效");
}
FeeSourceDetail before = getFinanceFeeSource(feeSourceId);
if (!"FIRSTLEG".equals(before.sourceSystem())) {
return transitionFeeSource(feeSourceId, operatorId, key, request, "FEE_SOURCE_VOID",
"VOIDED", List.of("DRAFT", "PENDING", "REJECTED", "APPROVED"), true);
}
Long replay = reserve(before.customerId(), operatorId, "FEE_SOURCE_VOID", key, "POST",
"/api/platform/finance/v1/fee-sources/" + feeSourceId + "/void", request);
if (replay != null) return getFinanceFeeSource(replay);
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可作废");
if (before.operationVersion() != request.expectedVersion()) {
throw error("FIN-FEE-409", "费用来源状态或版本已变化");
}
if (!List.of("DRAFT", "PENDING", "REJECTED", "APPROVED").contains(before.reviewStatus())
|| !"AVAILABLE".equals(before.sourceStatus())) {
throw error("FIN-FEE-409", "费用来源当前状态不允许作废");
}
FirstlegFeeSourceClient.VoidCommand command = new FirstlegFeeSourceClient.VoidCommand(
before.sourceType(), before.sourceFeeId(), before.sourceVersion(), before.customerId(),
before.inboundId(), before.containerId(), operatorId,
StringUtils.hasText(operatorName) ? operatorName.trim() : "SYSTEM", request.reason().trim());
try {
var response = firstleg.voidSource(internalToken, key, hash(command), command);
if (response == null || !response.successful() || response.data() == null
|| !"VOIDED".equals(response.data().status())) {
throw error("FIN-UPSTREAM-502", "Firstleg未确认费用来源作废");
}
} catch (FeignException exception) {
throw error("FIN-UPSTREAM-503", "Firstleg费用作废服务暂不可用");
}
audit(before.customerId(), feeSourceId, "FEE_SOURCE_VOID_REQUESTED",
request.reason(), before, before, operatorId);
complete(before.customerId(), operatorId, "FEE_SOURCE_VOID", key, feeSourceId);
return getFinanceFeeSource(feeSourceId);
}
private FeeSourceDetail transitionFeeSource(long feeSourceId, long operatorId, String key,
@@ -121,7 +121,7 @@ class FinanceFeeSourceEventServiceTest {
.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).update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND status<>'VOIDED'", 88L);
verify(jdbc, never()).update(startsWith("DELETE"), any(Object[].class));
}
@@ -1,6 +1,8 @@
package com.baiyue.finance;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.client.FirstlegFeeSourceClient;
import com.baiyue.finance.controller.FinancePhase1OperationController;
import com.baiyue.finance.dto.FinancePhase1Dtos;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
@@ -35,6 +37,7 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.startsWith;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -57,6 +60,7 @@ class FinancePhase1OperationBehaviorTest {
controller.getFinanceFeeSource(88L);
controller.reviewFinanceFeeSource(user, 88L, "review-key", review);
controller.submitFinanceFeeSource(user, 88L, "submit-key", action);
controller.voidFinanceFeeSource(user, 88L, "void-key", action);
verify(customerAccountService).list(1, 20, "柏悦", "ACTIVE", 9L);
verify(operationService).listDerivedCustomerAccountLedger(eq(9L), eq(2), eq(50), eq("RECEIPT"), any(), any());
@@ -65,6 +69,7 @@ class FinancePhase1OperationBehaviorTest {
verify(operationService).getFinanceFeeSource(88L);
verify(operationService).reviewFinanceFeeSource(88L, 71L, "review-key", review);
verify(operationService).submitFinanceFeeSource(88L, 71L, "submit-key", action);
verify(operationService).voidFinanceFeeSource(88L, 71L, "finance", "void-key", action);
}
@Test
@@ -220,29 +225,49 @@ class FinancePhase1OperationBehaviorTest {
}
@Test
void rejectedFeeCanBeEditedBackToDraftWithExpectedVersion() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName())
? 1 : org.mockito.Mockito.RETURNS_DEFAULTS.answer(invocation));
AtomicInteger reads = new AtomicInteger();
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<FeeSourceDetail> mapper = invocation.getArgument(1);
boolean changed = reads.getAndIncrement() > 0;
return List.of(mapper.mapRow(resultSet(changed ? "DRAFT" : "REJECTED", false, changed ? 2 : 1), 0));
});
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false);
when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
eq("FEE_SOURCE_EDIT"), eq("edit-key"))).thenReturn(List.of());
void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 1);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
FeeSourceDetail result = service.editFinanceFeeSource(88L, 71L, "edit-key",
new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("120.00"), "更正金额", 1), false);
var request = new FinancePhase1Dtos.FeeSourceEditRequest(
new BigDecimal("120.00"), "更正金额", 1);
assertThatThrownBy(() -> service.editFinanceFeeSource(88L, 71L, "edit-key", request, false))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("Firstleg费用必须");
assertThatThrownBy(() -> service.editFinanceFeeSource(88L, 71L, "adjust-key", request, true))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("Firstleg费用必须");
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET amount="),
(Object[]) any());
}
assertThat(result.reviewStatus()).isEqualTo("DRAFT");
assertThat(result.operationVersion()).isEqualTo(2);
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET amount="),
eq(new BigDecimal("120.00")), eq(88L), eq(1), eq("REJECTED"));
@Test
void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("APPROVED", false, 2);
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
eq("FEE_SOURCE_VOID"), eq("void-key"))).thenReturn(List.of());
FirstlegFeeSourceClient firstleg = mock(FirstlegFeeSourceClient.class);
when(firstleg.voidSource(eq("internal-token"), eq("void-key"), anyString(), any()))
.thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult(
"INBOUND_FEE", 700L, 3, "VOIDED", false)));
FinancePhase1OperationService service = new FinancePhase1OperationService(
jdbc, objectMapper(), firstleg, "internal-token");
FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance",
"void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2));
assertThat(result.sourceStatus()).isEqualTo("AVAILABLE");
var command = org.mockito.ArgumentCaptor.forClass(FirstlegFeeSourceClient.VoidCommand.class);
verify(firstleg).voidSource(eq("internal-token"), eq("void-key"), anyString(), command.capture());
assertThat(command.getValue()).satisfies(value -> {
assertThat(value.sourceType()).isEqualTo("INBOUND_FEE");
assertThat(value.sourceFeeId()).isEqualTo(700L);
assertThat(value.sourceVersion()).isEqualTo(3);
assertThat(value.customerId()).isEqualTo(9L);
assertThat(value.inboundId()).isEqualTo(100L);
assertThat(value.operatorName()).isEqualTo("finance");
assertThat(value.reason()).isEqualTo("测试作废");
});
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET status='VOIDED'"),
(Object[]) any());
}
@Test
@@ -299,6 +324,7 @@ class FinancePhase1OperationBehaviorTest {
when(rs.getLong("order_id")).thenReturn(90L);
when(rs.getLong("inbound_id")).thenReturn(100L);
when(rs.getLong("container_id")).thenReturn(200L);
when(rs.wasNull()).thenReturn(false, false, true, false, false, true);
when(rs.getInt("source_fee_version")).thenReturn(3);
when(rs.getString("source_system")).thenReturn("FIRSTLEG");
when(rs.getString("source_no")).thenReturn("IN-100");
@@ -312,7 +338,7 @@ class FinancePhase1OperationBehaviorTest {
when(rs.getString("review_reason")).thenReturn(null);
when(rs.getInt("operation_version")).thenReturn(version);
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("100.00"));
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\",\"reviewStatus\":\"" + reviewStatus + "\",\"publicationStatus\":\"PUBLISHED\",\"operationVersion\":" + version + "}");
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"inboundCode\":\"NYEXX2608180003\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\",\"reviewStatus\":\"" + reviewStatus + "\",\"publicationStatus\":\"PUBLISHED\",\"operationVersion\":" + version + "}");
return rs;
}
@@ -19,6 +19,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@@ -130,9 +131,15 @@ class FinanceReadApiBehaviorTest {
@Test
void clientConfirmationHasIndependentWriteAuthority() throws Exception {
PreAuthorize rule = ClientFinanceController.class.getMethod("confirmStatement", AuthenticatedUser.class,
long.class, String.class, CustomerConfirmationRequest.class).getAnnotation(PreAuthorize.class);
var method = ClientFinanceController.class.getMethod("confirmStatement", AuthenticatedUser.class,
long.class, String.class, CustomerConfirmationRequest.class);
PreAuthorize rule = method.getAnnotation(PreAuthorize.class);
PostMapping mapping = method.getAnnotation(PostMapping.class);
assertThat(rule.value()).isEqualTo("hasAuthority('merchant:finance:statement:confirm')");
assertThat(mapping.value()).containsExactly("/statements/{statementId}/confirm");
assertThat(CustomerConfirmationRequest.class.getRecordComponents())
.extracting(java.lang.reflect.RecordComponent::getName)
.containsExactly("expectedVersion");
}
}
@@ -0,0 +1,34 @@
package com.baiyue.finance;
import com.baiyue.finance.client.FirstlegFeeSourceClient;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import java.lang.reflect.RecordComponent;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class FirstlegFeeSourceClientContractTest {
@Test
void voidCommandFreezesInternalPathHeadersAndBusinessIdentity() throws Exception {
var method = FirstlegFeeSourceClient.class.getDeclaredMethod("voidSource",
String.class, String.class, String.class, FirstlegFeeSourceClient.VoidCommand.class);
assertThat(method.getAnnotation(PostMapping.class).value())
.containsExactly("/fee-sources/commands/void");
assertThat(method.getParameters()[0].getAnnotation(RequestHeader.class).value())
.isEqualTo("X-Internal-Service-Token");
assertThat(method.getParameters()[1].getAnnotation(RequestHeader.class).value())
.isEqualTo("Idempotency-Key");
assertThat(method.getParameters()[2].getAnnotation(RequestHeader.class).value())
.isEqualTo("X-Request-Hash");
assertThat(method.getParameters()[3].isAnnotationPresent(RequestBody.class)).isTrue();
assertThat(Arrays.stream(FirstlegFeeSourceClient.VoidCommand.class.getRecordComponents())
.map(RecordComponent::getName))
.containsExactly("sourceType", "sourceFeeId", "sourceVersion", "customerId",
"inboundId", "containerId", "operatorId", "operatorName", "reason");
}
}