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
Vendored
+14 -6
View File
@@ -65,10 +65,18 @@ pipeline {
stage('确定构建范围') {
steps {
script {
// 开发环境总控 Job 固定部署到 development,避免首次构建沿用 ENVIRONMENT 的默认 test 值。
// 其他 Job(包括现有 backend-java 测试环境)仍完全沿用原有参数。
String resolvedEnvironment = env.JOB_BASE_NAME == 'backend-java-dev'
? 'development'
: params.ENVIRONMENT
env.RESOLVED_ENVIRONMENT = resolvedEnvironment
echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}"
String requestedRepository
if (['development', 'test'].contains(params.ENVIRONMENT)) {
requestedRepository = "baiyue-firstleg-${params.ENVIRONMENT}"
echo "${params.ENVIRONMENT} 环境使用本地构建镜像,不登录、不推送远程 Registry"
if (['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) {
requestedRepository = "baiyue-firstleg-${env.RESOLVED_ENVIRONMENT}"
echo "${env.RESOLVED_ENVIRONMENT} 环境使用本地构建镜像,不登录、不推送远程 Registry"
} else {
requestedRepository = params.IMAGE_REPOSITORY?.trim()
if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') {
@@ -147,7 +155,7 @@ pipeline {
stage('生产环境部署确认(仅 production 执行)') {
when {
allOf {
expression { params.DEPLOY && params.ENVIRONMENT == 'production' && env.NO_SERVICE_CHANGE != 'true' }
expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' && env.NO_SERVICE_CHANGE != 'true' }
}
}
steps {
@@ -158,7 +166,7 @@ pipeline {
stage('测试数据库初始化(可选)') {
when {
allOf {
expression { params.ENVIRONMENT == 'test' && params.INITIALIZE_TEST_DATABASE }
expression { env.RESOLVED_ENVIRONMENT == 'test' && params.INITIALIZE_TEST_DATABASE }
}
}
steps {
@@ -183,7 +191,7 @@ pipeline {
parameters: [
string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT),
string(name: 'IMAGE_TAG', value: env.IMAGE_TAG),
string(name: 'ENVIRONMENT', value: params.ENVIRONMENT),
string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT),
booleanParam(name: 'DEPLOY', value: params.DEPLOY),
booleanParam(name: 'SKIP_TESTS', value: params.SKIP_TESTS),
string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY),
@@ -14691,7 +14691,8 @@ VALUES
(5205000, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:finance:statement', '财务账单', '/finance/statements', NULL, 40, 'ACTIVE', 0),
(5205010, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:list', '查看账单', NULL, 'GET', 10, 'ACTIVE', 0),
(5205011, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:view', '查看账单详情及收款', NULL, 'GET', 20, 'ACTIVE', 0),
(5205012, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:confirm', '确认客户对账单', NULL, 'POST', 30, 'ACTIVE', 0)
(5205012, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:confirm', '确认客户对账单', NULL, 'POST', 30, 'ACTIVE', 0),
(5305012, 0, 'MERCHANT', 'BUTTON', 5305000, 'merchant:finance:statement:confirm', '确认客户对账单', NULL, 'POST', 30, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), parent_id=VALUES(parent_id), route_path=VALUES(route_path), http_method=VALUES(http_method), status='ACTIVE', deleted=0;
DELETE rp FROM baiyue_iam.iam_role_permission rp
@@ -15292,6 +15293,13 @@ SELECT 10001, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permissi
WHERE r.tenant_id=10001 AND r.app_code='MERCHANT' AND r.role_code IN ('MERCHANT_OWNER','MERCHANT_ADMIN')
AND p.permission_code='merchant:finance:statement:confirm';
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='MERCHANT'
WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER'
AND r.status='ACTIVE' AND r.deleted=0
AND p.permission_code='merchant:finance:statement:confirm'
AND p.status='ACTIVE' AND p.deleted=0;
INSERT INTO baiyue_iam.iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
VALUES
@@ -15514,6 +15522,9 @@ BEGIN
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=10001 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement:list','merchant:finance:statement:view','merchant:finance:statement:confirm') AND status='ACTIVE' AND deleted=0) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户 Finance 查询与确认权限不完整';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='MERCHANT' AND permission_code='merchant:finance:statement:confirm' AND status='ACTIVE' AND deleted=0) <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局对账单确认权限不完整';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND (p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 授权不完整';
END IF;
@@ -15523,6 +15534,9 @@ BEGIN
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000101 AND rp.tenant_id=10001 AND (p.permission_code='merchant:finance:statement' OR p.permission_code LIKE 'merchant:finance:%')) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户管理员 Finance 查询与确认授权不完整';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 AND p.permission_code='merchant:finance:statement:confirm' AND p.status='ACTIVE' AND p.deleted=0) <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局管理员缺少对账单确认权限';
END IF;
END$$
DELIMITER ;
CALL assert_finance_permissions();
@@ -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");
}
}
@@ -4,7 +4,11 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FinanceFeeSourceSnapshot;
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.CommandResult;
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.VoidRequest;
import com.baiyue.firstleg.service.FinanceFeeSourceCommandService;
import com.baiyue.firstleg.service.FinanceFeeSourceSnapshotService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
@@ -12,6 +16,8 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
@@ -22,6 +28,7 @@ import java.security.MessageDigest;
@RequestMapping("/internal/firstleg/v1")
public class InternalFinanceFeeSourceController {
private final FinanceFeeSourceSnapshotService service;
private final FinanceFeeSourceCommandService commands;
@Value("${baiyue.security.internal-service-token}")
private String expectedToken;
@@ -43,6 +50,16 @@ public class InternalFinanceFeeSourceController {
return ApiResponse.success(service.container(containerId, feeId, sourceVersion));
}
@PostMapping("/fee-sources/commands/void")
public ApiResponse<CommandResult> voidSource(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestHeader("X-Request-Hash") String requestHash,
@Valid @RequestBody VoidRequest request) {
authenticate(token);
return ApiResponse.success(commands.voidSource(idempotencyKey, requestHash, request));
}
private void authenticate(String token) {
if (token == null || expectedToken == null || expectedToken.isBlank()
|| !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
/** Finance 发起、Firstleg 执行的业务费用命令契约。 */
public final class FinanceFeeSourceCommandDtos {
private FinanceFeeSourceCommandDtos() {}
public record VoidRequest(
@NotBlank @Pattern(regexp = "INBOUND_FEE|CONTAINER_FEE") String sourceType,
@Min(1) long sourceFeeId,
@Min(1) int sourceVersion,
@Min(1) long customerId,
Long inboundId,
Long containerId,
@Min(1) long operatorId,
@NotBlank @Size(max = 64) String operatorName,
@NotBlank @Size(max = 500) String reason
) {}
public record CommandResult(
@NotNull String sourceType,
long sourceFeeId,
int sourceVersion,
@NotNull String status,
boolean replayed
) {}
}
@@ -0,0 +1,33 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_finance_fee_source_command")
public class FinanceFeeSourceCommandEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long customerId;
private Long operatorId;
private String operatorName;
private String action;
private String idempotencyKey;
private String requestHash;
private String sourceType;
private Long sourceFeeId;
private Integer sourceVersion;
private Long inboundId;
private Long containerId;
private String reason;
private String status;
private String resultStatus;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.FinanceFeeSourceCommandEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface FinanceFeeSourceCommandMapper extends BaseMapper<FinanceFeeSourceCommandEntity> {
}
@@ -258,6 +258,26 @@ public class FbaPricingRepository {
.set(InboundFeeEntity::getSentAt, now));
}
public int voidSentFee(long tenantId, long inboundId, long feeId, int feeVersion,
LocalDateTime now) {
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId)
.eq(InboundFeeEntity::getInboundId, inboundId)
.eq(InboundFeeEntity::getId, feeId)
.eq(InboundFeeEntity::getFeeVersion, feeVersion)
.eq(InboundFeeEntity::getFeeStatus, "SENT")
.set(InboundFeeEntity::getFeeStatus, "VOIDED")
.set(InboundFeeEntity::getUpdatedAt, now));
}
public int markInboundFeeVoided(long tenantId, long inboundId) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
.eq(InboundPricingEntity::getId, inboundId)
.eq(InboundPricingEntity::getFeeStatus, "SENT")
.set(InboundPricingEntity::getFeeStatus, "VOIDED"));
}
public int markInboundFeeSent(long tenantId, long inboundId, String processStatus) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
@@ -0,0 +1,38 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.firstleg.entity.FinanceFeeSourceCommandEntity;
import com.baiyue.firstleg.mapper.FinanceFeeSourceCommandMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
@Repository
@RequiredArgsConstructor
public class FinanceFeeSourceCommandRepository {
private final FinanceFeeSourceCommandMapper mapper;
public void insert(FinanceFeeSourceCommandEntity value) {
mapper.insert(value);
}
public FinanceFeeSourceCommandEntity find(long customerId, long operatorId,
String action, String key) {
return mapper.selectOne(new LambdaQueryWrapper<FinanceFeeSourceCommandEntity>()
.eq(FinanceFeeSourceCommandEntity::getCustomerId, customerId)
.eq(FinanceFeeSourceCommandEntity::getOperatorId, operatorId)
.eq(FinanceFeeSourceCommandEntity::getAction, action)
.eq(FinanceFeeSourceCommandEntity::getIdempotencyKey, key));
}
public int complete(long id, String resultStatus, LocalDateTime completedAt) {
return mapper.update(null, new LambdaUpdateWrapper<FinanceFeeSourceCommandEntity>()
.eq(FinanceFeeSourceCommandEntity::getId, id)
.eq(FinanceFeeSourceCommandEntity::getStatus, "PROCESSING")
.set(FinanceFeeSourceCommandEntity::getStatus, "SUCCEEDED")
.set(FinanceFeeSourceCommandEntity::getResultStatus, resultStatus)
.set(FinanceFeeSourceCommandEntity::getCompletedAt, completedAt));
}
}
@@ -0,0 +1,192 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.CommandResult;
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.VoidRequest;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.FinanceFeeSourceCommandEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import com.baiyue.firstleg.repository.FinanceFeeSourceCommandRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
/** Finance 仅发起命令;业务状态与费用事件始终由 Firstleg 原子产生。 */
@Service
@RequiredArgsConstructor
public class FinanceFeeSourceCommandService {
private static final String ACTION_VOID = "VOID";
private final FinanceFeeSourceCommandRepository commands;
private final FeeItemRepository inboundFees;
private final FbaPricingRepository pricing;
private final ContainerFeeDetailRepository containerFees;
private final ContainerFeeDetailService containerFeeService;
private final FinanceFeeSourceOutboxService financeOutbox;
private final ObjectMapper objectMapper;
@Transactional
public CommandResult voidSource(String idempotencyKey, String clientHash, VoidRequest request) {
validateKey(idempotencyKey);
validateIdentity(request);
String requestHash = hash(request);
if (!StringUtils.hasText(clientHash) || !requestHash.equalsIgnoreCase(clientHash.trim())) {
throw bad("X-Request-Hash与费用作废请求不一致");
}
FinanceFeeSourceCommandEntity command = claim(idempotencyKey.trim(), requestHash, request);
if ("SUCCEEDED".equals(command.getStatus())) {
return result(request, command.getResultStatus(), true);
}
if ("INBOUND_FEE".equals(request.sourceType())) voidInbound(request);
else voidContainer(request);
if (commands.complete(command.getId(), "VOIDED", LocalDateTime.now()) != 1) {
throw conflict("费用作废幂等记录状态已变化");
}
return result(request, "VOIDED", false);
}
private FinanceFeeSourceCommandEntity claim(String key, String requestHash, VoidRequest request) {
FinanceFeeSourceCommandEntity value = new FinanceFeeSourceCommandEntity();
value.setCustomerId(request.customerId());
value.setOperatorId(request.operatorId());
value.setOperatorName(request.operatorName().trim());
value.setAction(ACTION_VOID);
value.setIdempotencyKey(key);
value.setRequestHash(requestHash);
value.setSourceType(request.sourceType());
value.setSourceFeeId(request.sourceFeeId());
value.setSourceVersion(request.sourceVersion());
value.setInboundId(request.inboundId());
value.setContainerId(request.containerId());
value.setReason(request.reason().trim());
value.setStatus("PROCESSING");
value.setCreatedAt(LocalDateTime.now());
try {
commands.insert(value);
return value;
} catch (DuplicateKeyException duplicate) {
FinanceFeeSourceCommandEntity existing = commands.find(request.customerId(),
request.operatorId(), ACTION_VOID, key);
if (existing == null || !requestHash.equals(existing.getRequestHash())) {
throw conflict("Idempotency-Key已用于其他费用作废请求");
}
if (!"SUCCEEDED".equals(existing.getStatus())) {
throw conflict("相同费用作废请求正在处理中");
}
return existing;
}
}
private void voidInbound(VoidRequest request) {
long inboundId = positive(request.inboundId(), "入库单ID");
if (request.containerId() != null) throw bad("入库费用不能携带柜次ID");
InboundFeeDetailEntity detail = inboundFees.findSourceVersion(inboundId,
request.sourceFeeId(), request.sourceVersion());
if (detail == null || !Long.valueOf(request.customerId()).equals(detail.getTenantId())) {
throw new BizException(ResultCode.NOT_FOUND, "指定客户的入库费用来源版本不存在");
}
if ("VOIDED".equals(detail.getPublicationStatus())) return;
if (!"APPROVED".equals(detail.getReviewStatus())
|| !"PUBLISHED".equals(detail.getPublicationStatus())) {
throw conflict("只有当前已审核发布的入库费用来源可以作废");
}
LocalDateTime now = LocalDateTime.now();
if ("FBA_PRICING".equals(detail.getSourceType())) {
if (pricing.voidSentFee(request.customerId(), inboundId, request.sourceFeeId(),
request.sourceVersion(), now) != 1
|| pricing.markInboundFeeVoided(request.customerId(), inboundId) != 1) {
throw conflict("入库计价费用状态或版本已变化");
}
} else if (!"MANUAL".equals(detail.getSourceType())) {
throw conflict("该入库费用来源类型不支持作废");
}
if (inboundFees.voidApproved(request.customerId(), inboundId, detail.getId(),
request.sourceVersion(), request.operatorId()) != 1) {
throw conflict("入库费用状态或版本已变化");
}
financeOutbox.publishInbound(FinanceFeeSourceOutboxService.VOIDED,
request.customerId(), inboundId, request.sourceFeeId(), request.sourceVersion(), now);
}
private void voidContainer(VoidRequest request) {
long containerId = positive(request.containerId(), "柜次ID");
if (request.inboundId() != null) throw bad("柜次费用不能携带入库单ID");
ContainerFeeDetailEntity detail = containerFees.find(containerId, request.sourceFeeId());
if (detail == null || !Long.valueOf(request.customerId()).equals(detail.getTenantId())) {
throw new BizException(ResultCode.NOT_FOUND, "指定客户的柜次费用来源版本不存在");
}
if (!Integer.valueOf(request.sourceVersion()).equals(detail.getSourceVersion())) {
throw conflict("柜次费用来源版本已变化");
}
if ("VOIDED".equals(detail.getSourceStatus())) return;
containerFeeService.voidApproved(request.customerId(), containerId,
request.sourceFeeId(), request.sourceVersion(), request.operatorId());
}
private String hash(VoidRequest request) {
try {
return sha256(objectMapper.writeValueAsString(request));
} catch (JsonProcessingException exception) {
throw new BizException(ResultCode.INTERNAL_ERROR, "费用作废请求序列化失败");
}
}
private static void validateIdentity(VoidRequest request) {
if (request == null || request.sourceFeeId() <= 0 || request.sourceVersion() <= 0
|| request.customerId() <= 0 || request.operatorId() <= 0
|| !StringUtils.hasText(request.operatorName()) || !StringUtils.hasText(request.reason())) {
throw bad("费用作废请求无效");
}
if (!"INBOUND_FEE".equals(request.sourceType())
&& !"CONTAINER_FEE".equals(request.sourceType())) {
throw bad("费用来源类型无效");
}
}
private static void validateKey(String key) {
if (!StringUtils.hasText(key) || key.trim().length() > 128) {
throw bad("Idempotency-Key不能为空且不能超过128个字符");
}
}
private static long positive(Long value, String label) {
if (value == null || value <= 0) throw bad(label + "无效");
return value;
}
private static CommandResult result(VoidRequest request, String status, boolean replayed) {
return new CommandResult(request.sourceType(), request.sourceFeeId(),
request.sourceVersion(), status, replayed);
}
private static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 unavailable", exception);
}
}
private static BizException bad(String message) {
return new BizException(ResultCode.BAD_REQUEST, message);
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}
@@ -125,6 +125,32 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot (
KEY idx_container_fee_snapshot_tenant (tenant_id, loading_plan_id)
) ENGINE=InnoDB COMMENT='柜次费用不可变版本快照';
CREATE TABLE IF NOT EXISTS fl_finance_fee_source_command (
id BIGINT UNSIGNED NOT NULL COMMENT 'Finance费用来源命令ID',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,由Firstleg费用事实再次校验',
operator_id BIGINT UNSIGNED NOT NULL COMMENT 'Finance操作人ID',
operator_name VARCHAR(64) NOT NULL COMMENT 'Finance操作人名称快照',
action VARCHAR(24) NOT NULL COMMENT '命令动作;当前仅VOID',
idempotency_key VARCHAR(128) NOT NULL COMMENT 'Finance透传的幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256',
source_type VARCHAR(24) NOT NULL COMMENT 'INBOUND_FEE或CONTAINER_FEE',
source_fee_id BIGINT UNSIGNED NOT NULL COMMENT '稳定费用来源ID',
source_version INT UNSIGNED NOT NULL COMMENT '精确费用来源版本',
inbound_id BIGINT UNSIGNED NULL COMMENT '入库费用所属入库单ID',
container_id BIGINT UNSIGNED NULL COMMENT '柜次费用所属柜次ID',
reason VARCHAR(500) NOT NULL COMMENT '操作原因',
status VARCHAR(16) NOT NULL COMMENT 'PROCESSING或SUCCEEDED',
result_status VARCHAR(16) NULL COMMENT '成功业务结果;当前为VOIDED',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_finance_fee_command_idem (customer_id, operator_id, action, idempotency_key),
KEY idx_finance_fee_command_source (source_type, source_fee_id, source_version, created_at),
CONSTRAINT chk_finance_fee_command_action CHECK (action IN ('VOID')),
CONSTRAINT chk_finance_fee_command_status CHECK (status IN ('PROCESSING', 'SUCCEEDED')),
CONSTRAINT chk_finance_fee_command_source CHECK (source_type IN ('INBOUND_FEE', 'CONTAINER_FEE'))
) ENGINE=InnoDB COMMENT='Finance发起、Firstleg执行业务费用命令的幂等审计记录';
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id',
"BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_name',
@@ -153,6 +179,10 @@ BEGIN
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用表不存在';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'fl_finance_fee_source_command') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:费用命令幂等审计表不存在';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail'
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN
@@ -635,7 +635,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee (
subtotal_amount DECIMAL(18,2) NOT NULL COMMENT '基础费用金额',
additional_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '附加费金额',
total_amount DECIMAL(18,2) NOT NULL COMMENT '总金额',
fee_status VARCHAR(24) NOT NULL DEFAULT 'CALCULATED' COMMENT '费用状态:CALCULATED已计算、SUPERSEDED已被新版本替代',
fee_status VARCHAR(24) NOT NULL DEFAULT 'CALCULATED' COMMENT '费用状态:CALCULATED已计算、CONFIRMED已确认、SENT已发送、SUPERSEDED已被新版本替代、VOIDED已作废',
review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回、CONFIRMED已确认',
exception_release TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否基于测量异常放行生成',
calculated_by BIGINT UNSIGNED NOT NULL COMMENT '试算人ID',
@@ -94,6 +94,25 @@ paths:
- {name: sourceVersion, in: query, required: true, schema: {type: integer, minimum: 1}}
responses:
'200': {description: 成功时 data 为 FinanceFeeSourceSnapshot;不存在精确版本时返回业务错误}
/internal/firstleg/v1/fee-sources/commands/void:
post:
summary: Finance 发起 Firstleg 业务费用作废
description: >-
Finance 只发起命令,不修改或伪造业务费用状态。Firstleg 按 sourceType、sourceFeeId、sourceVersion、
customerId 和所属入库单/柜次精确校验,原子作废业务费用并写入 FIRSTLEG_FEE_VOIDED Outbox;
Finance 后续仅消费该事件同步本地费用池。相同幂等键与请求哈希安全重放,异参重放返回冲突。
security:
- internalServiceToken: []
parameters:
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
- {name: X-Request-Hash, in: header, required: true, schema: {type: string, pattern: '^[0-9a-f]{64}$'}}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/FinanceFeeSourceVoidCommand'}
responses:
'200': {description: 成功时 data 为 FinanceFeeSourceCommandResult;业务失败仍按统一响应 code 返回}
/api/platform/v1/first-leg/customer-dimension-config:
get:
summary: 查询客户固定四箱规尺寸配置
@@ -1879,3 +1898,26 @@ components:
actualVolumeCbm: {type: number, nullable: true}
sourceHash: {type: string}
snapshotVersion: {type: integer, minimum: 1}
FinanceFeeSourceVoidCommand:
type: object
additionalProperties: false
required: [sourceType, sourceFeeId, sourceVersion, customerId, operatorId, operatorName, reason]
properties:
sourceType: {type: string, enum: [INBOUND_FEE, CONTAINER_FEE]}
sourceFeeId: {type: integer, format: int64, minimum: 1}
sourceVersion: {type: integer, minimum: 1}
customerId: {type: integer, format: int64, minimum: 1}
inboundId: {type: integer, format: int64, minimum: 1, nullable: true, description: INBOUND_FEE必填且containerId必须为空}
containerId: {type: integer, format: int64, minimum: 1, nullable: true, description: CONTAINER_FEE必填且inboundId必须为空}
operatorId: {type: integer, format: int64, minimum: 1}
operatorName: {type: string, minLength: 1, maxLength: 64}
reason: {type: string, minLength: 1, maxLength: 500}
FinanceFeeSourceCommandResult:
type: object
required: [sourceType, sourceFeeId, sourceVersion, status, replayed]
properties:
sourceType: {type: string, enum: [INBOUND_FEE, CONTAINER_FEE]}
sourceFeeId: {type: integer, format: int64}
sourceVersion: {type: integer}
status: {type: string, enum: [VOIDED]}
replayed: {type: boolean}
@@ -2,6 +2,8 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.service.FinanceFeeSourceSnapshotService;
import com.baiyue.firstleg.service.FinanceFeeSourceCommandService;
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.VoidRequest;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.test.util.ReflectionTestUtils;
@@ -15,6 +17,7 @@ import static org.mockito.Mockito.verify;
class InternalFinanceFeeSourceControllerTest {
private final FinanceFeeSourceSnapshotService service = mock(FinanceFeeSourceSnapshotService.class);
private final FinanceFeeSourceCommandService commands = mock(FinanceFeeSourceCommandService.class);
private final InternalFinanceFeeSourceController controller = controller();
@Test
@@ -34,6 +37,16 @@ class InternalFinanceFeeSourceControllerTest {
verify(service).container(41L, 51L, 3);
}
@Test
void routesAuthorizedVoidCommandWithIdempotencyAndHash() {
VoidRequest request = new VoidRequest("INBOUND_FEE", 31L, 2, 9L,
21L, null, 71L, "finance", "测试作废");
controller.voidSource("finance-token", "void-key", "a".repeat(64), request);
verify(commands).voidSource("void-key", "a".repeat(64), request);
}
@Test
void exposesFrozenInternalPaths() {
var mapping = InternalFinanceFeeSourceController.class
@@ -52,11 +65,14 @@ class InternalFinanceFeeSourceControllerTest {
.contains("/internal/firstleg/v1/containers/{containerId}/fees/{feeId}/source-snapshot:")
.contains("{name: sourceVersion, in: query, required: true")
.contains("supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true")
.contains("为空时仅要求供应商ACTIVE");
.contains("为空时仅要求供应商ACTIVE")
.contains("/internal/firstleg/v1/fee-sources/commands/void:")
.contains("FIRSTLEG_FEE_VOIDED Outbox")
.contains("FinanceFeeSourceVoidCommand:");
}
private InternalFinanceFeeSourceController controller() {
InternalFinanceFeeSourceController value = new InternalFinanceFeeSourceController(service);
InternalFinanceFeeSourceController value = new InternalFinanceFeeSourceController(service, commands);
ReflectionTestUtils.setField(value, "expectedToken", "finance-token");
return value;
}
@@ -37,6 +37,9 @@ class FinanceFeeSourceMigrationContractTest {
"source_version", "supplier_id", "supplier_name", "supplier_category",
"CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot",
"uk_container_fee_source_version",
"CREATE TABLE IF NOT EXISTS fl_finance_fee_source_command",
"uk_finance_fee_command_idem",
"request_hash CHAR(64) NOT NULL",
"firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id'", "RECEIVABLE", "PAYABLE",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'",
"Firstleg Finance迁移失败:入库费用供应商快照字段不完整",
@@ -0,0 +1,159 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.VoidRequest;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.FinanceFeeSourceCommandEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import com.baiyue.firstleg.repository.FinanceFeeSourceCommandRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DuplicateKeyException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class FinanceFeeSourceCommandServiceTest {
@Mock FinanceFeeSourceCommandRepository commands;
@Mock FeeItemRepository inboundFees;
@Mock FbaPricingRepository pricing;
@Mock ContainerFeeDetailRepository containerFees;
@Mock ContainerFeeDetailService containerFeeService;
@Mock FinanceFeeSourceOutboxService outbox;
private ObjectMapper objectMapper;
private FinanceFeeSourceCommandService service;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper().findAndRegisterModules();
service = new FinanceFeeSourceCommandService(commands, inboundFees, pricing,
containerFees, containerFeeService, outbox, objectMapper);
doAnswer(invocation -> {
FinanceFeeSourceCommandEntity value = invocation.getArgument(0);
value.setId(901L);
return null;
}).when(commands).insert(any());
lenient().when(commands.complete(eq(901L), eq("VOIDED"), any())).thenReturn(1);
}
@Test
void voidsManualInboundBusinessFactAndPublishesEvent() throws Exception {
VoidRequest request = inboundRequest(700L, 2);
InboundFeeDetailEntity detail = inboundDetail("MANUAL", 777L, 700L, 2, 9L);
when(inboundFees.findSourceVersion(100L, 700L, 2)).thenReturn(detail);
when(inboundFees.voidApproved(9L, 100L, 777L, 2, 71L)).thenReturn(1);
var result = service.voidSource("void-key", hash(request), request);
assertThat(result.status()).isEqualTo("VOIDED");
assertThat(result.replayed()).isFalse();
verify(outbox).publishInbound(org.mockito.ArgumentMatchers.eq(FinanceFeeSourceOutboxService.VOIDED),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(100L),
org.mockito.ArgumentMatchers.eq(700L), org.mockito.ArgumentMatchers.eq(2), any());
verify(pricing, never()).voidSentFee(anyLong(), anyLong(), anyLong(), anyInt(), any());
}
@Test
void voidsFbaPricingAndMaterializedInboundDetailAtomically() throws Exception {
VoidRequest request = inboundRequest(700L, 3);
when(inboundFees.findSourceVersion(100L, 700L, 3))
.thenReturn(inboundDetail("FBA_PRICING", 778L, 700L, 3, 9L));
when(pricing.voidSentFee(9L, 100L, 700L, 3, any())).thenReturn(1);
when(pricing.markInboundFeeVoided(9L, 100L)).thenReturn(1);
when(inboundFees.voidApproved(9L, 100L, 778L, 3, 71L)).thenReturn(1);
service.voidSource("void-fba", hash(request), request);
verify(pricing).voidSentFee(9L, 100L, 700L, 3, any());
verify(pricing).markInboundFeeVoided(9L, 100L);
verify(outbox).publishInbound(org.mockito.ArgumentMatchers.eq(FinanceFeeSourceOutboxService.VOIDED),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(100L),
org.mockito.ArgumentMatchers.eq(700L), org.mockito.ArgumentMatchers.eq(3), any());
}
@Test
void routesContainerVoidThroughExistingBusinessService() throws Exception {
VoidRequest request = new VoidRequest("CONTAINER_FEE", 800L, 4, 9L,
null, 200L, 71L, "finance", "柜次费用作废");
ContainerFeeDetailEntity detail = new ContainerFeeDetailEntity();
detail.setId(800L); detail.setTenantId(9L); detail.setLoadingPlanId(200L);
detail.setSourceVersion(4); detail.setSourceStatus("ACTIVE");
when(containerFees.find(200L, 800L)).thenReturn(detail);
service.voidSource("void-container", hash(request), request);
verify(containerFeeService).voidApproved(9L, 200L, 800L, 4, 71L);
}
@Test
void rejectsCrossCustomerSourceBeforeBusinessMutation() throws Exception {
VoidRequest request = inboundRequest(700L, 2);
when(inboundFees.findSourceVersion(100L, 700L, 2))
.thenReturn(inboundDetail("MANUAL", 777L, 700L, 2, 10L));
assertThatThrownBy(() -> service.voidSource("void-cross", hash(request), request))
.isInstanceOf(BizException.class).hasMessageContaining("指定客户");
verify(inboundFees, never()).voidApproved(anyLong(), anyLong(), anyLong(), anyInt(), anyLong());
verify(outbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
}
@Test
void replaysCompletedCommandWithoutRepeatingBusinessMutation() throws Exception {
VoidRequest request = inboundRequest(700L, 2);
String requestHash = hash(request);
doThrow(new DuplicateKeyException("duplicate")).when(commands).insert(any());
FinanceFeeSourceCommandEntity existing = new FinanceFeeSourceCommandEntity();
existing.setRequestHash(requestHash); existing.setStatus("SUCCEEDED");
existing.setResultStatus("VOIDED");
when(commands.find(9L, 71L, "VOID", "void-replay")).thenReturn(existing);
var result = service.voidSource("void-replay", requestHash, request);
assertThat(result.replayed()).isTrue();
verify(inboundFees, never()).findSourceVersion(anyLong(), anyLong(), anyInt());
}
private VoidRequest inboundRequest(long feeId, int version) {
return new VoidRequest("INBOUND_FEE", feeId, version, 9L,
100L, null, 71L, "finance", "入库费用作废");
}
private static InboundFeeDetailEntity inboundDetail(String sourceType, long id,
long sourceFeeId, int version,
long tenantId) {
InboundFeeDetailEntity value = new InboundFeeDetailEntity();
value.setId(id); value.setTenantId(tenantId); value.setInboundId(100L);
value.setSourceType(sourceType); value.setSourceFeeId(sourceFeeId);
value.setSourceFeeVersion(version); value.setReviewStatus("APPROVED");
value.setPublicationStatus("PUBLISHED");
return value;
}
private String hash(VoidRequest request) throws Exception {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8)));
}
}
@@ -29,7 +29,8 @@ VALUES
(5105053,0,'PLATFORM','BUTTON',5105043,'platform:finance:settlement-ar:export','导出应收结算单',NULL,'POST',90,'ACTIVE',1,0),
(5105054,0,'PLATFORM','BUTTON',5105044,'platform:finance:settlement-ap:export','导出应付结算单',NULL,'POST',90,'ACTIVE',1,0),
(5105055,0,'PLATFORM','BUTTON',5105045,'platform:finance:receivable-statistics:export','导出应收款统计表',NULL,'POST',90,'ACTIVE',1,0),
(5205012,10001,'MERCHANT','BUTTON',5205000,'merchant:finance:statement:confirm','确认客户对账单',NULL,'POST',30,'ACTIVE',1,0)
(5205012,10001,'MERCHANT','BUTTON',5205000,'merchant:finance:statement:confirm','确认客户对账单',NULL,'POST',30,'ACTIVE',1,0),
(5305012,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:confirm','确认客户对账单',NULL,'POST',30,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE
permission_type=VALUES(permission_type), parent_id=VALUES(parent_id),
permission_name=VALUES(permission_name), route_path=VALUES(route_path),
@@ -106,3 +107,12 @@ SELECT 10001, r.id, p.id, CURRENT_TIMESTAMP(3)
JOIN iam_permission p ON p.tenant_id=10001 AND p.app_code='MERCHANT'
WHERE r.tenant_id=10001 AND r.app_code='MERCHANT' AND r.role_code IN ('MERCHANT_OWNER','MERCHANT_ADMIN')
AND p.permission_code='merchant:finance:statement:confirm';
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3)
FROM iam_role r
JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='MERCHANT'
WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER'
AND r.status='ACTIVE' AND r.deleted=0
AND p.permission_code='merchant:finance:statement:confirm'
AND p.status='ACTIVE' AND p.deleted=0;
@@ -31,6 +31,8 @@ class AdminPrototypeMenuPermissionSqlContractTest {
assertThat(sql).contains("(" + (5105050 + index) + ",0,'PLATFORM','BUTTON'," + (5105040 + index) + ",'" + FINANCE_EXPORTS[index] + "'");
}
assertThat(sql)
.contains("(5305012,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:confirm'")
.contains("r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER'")
.contains("'platform:finance:fee-source:review' code, 5105041 parent_id")
.contains("'platform:finance:fee-source:edit', 5105041")
.contains("'platform:finance:reconciliation:create', 5105042")
@@ -59,6 +61,7 @@ class AdminPrototypeMenuPermissionSqlContractTest {
.contains("(p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33")
.contains("Finance 导出和下载中心授权不完整")
.contains("merchant:finance:statement:confirm")
.contains("客户全局管理员缺少对账单确认权限")
.satisfies(content -> assertThat(content.indexOf("(5105050,0,'PLATFORM','BUTTON'"))
.isLessThan(content.indexOf("CREATE PROCEDURE assert_finance_permissions()")));
}