diff --git a/Jenkinsfile b/Jenkinsfile index b8adbf11d..998a3bbe1 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -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), diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 6bc836a04..c9402cdb2 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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(); diff --git a/finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java b/finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java index 3116463ee..14d00cad0 100644 --- a/finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java +++ b/finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java @@ -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 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, diff --git a/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java b/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java index c21a6a710..1dffaf72d 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java @@ -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)); } } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java index 816201624..d501a7f32 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java @@ -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; } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java index 40ed549f8..3ce52020d 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -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 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, diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java index c3cbbb946..549bdee75 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java @@ -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)); } diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java index 010ec0274..03e429d9c 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java @@ -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 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; } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java index 258384877..b0643af39 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java @@ -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"); } } diff --git a/finance/src/test/java/com/baiyue/finance/FirstlegFeeSourceClientContractTest.java b/finance/src/test/java/com/baiyue/finance/FirstlegFeeSourceClientContractTest.java new file mode 100644 index 000000000..de4e90a4b --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FirstlegFeeSourceClientContractTest.java @@ -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"); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalFinanceFeeSourceController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalFinanceFeeSourceController.java index f56321de8..f9fcb1825 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalFinanceFeeSourceController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalFinanceFeeSourceController.java @@ -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 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), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/FinanceFeeSourceCommandDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/FinanceFeeSourceCommandDtos.java new file mode 100644 index 000000000..514d34f3a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/FinanceFeeSourceCommandDtos.java @@ -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 + ) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/FinanceFeeSourceCommandEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/FinanceFeeSourceCommandEntity.java new file mode 100644 index 000000000..1715b7f08 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/FinanceFeeSourceCommandEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/FinanceFeeSourceCommandMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/FinanceFeeSourceCommandMapper.java new file mode 100644 index 000000000..e7bec5cc5 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/FinanceFeeSourceCommandMapper.java @@ -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 { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index e0f5201de..db62714e6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -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() + .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() + .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 update = new LambdaUpdateWrapper() .eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FinanceFeeSourceCommandRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FinanceFeeSourceCommandRepository.java new file mode 100644 index 000000000..6b5eaca3b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FinanceFeeSourceCommandRepository.java @@ -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() + .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() + .eq(FinanceFeeSourceCommandEntity::getId, id) + .eq(FinanceFeeSourceCommandEntity::getStatus, "PROCESSING") + .set(FinanceFeeSourceCommandEntity::getStatus, "SUCCEEDED") + .set(FinanceFeeSourceCommandEntity::getResultStatus, resultStatus) + .set(FinanceFeeSourceCommandEntity::getCompletedAt, completedAt)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceCommandService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceCommandService.java new file mode 100644 index 000000000..1e445122a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceCommandService.java @@ -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); + } +} diff --git a/firstleg/src/main/resources/db/finance-fee-source-migration.sql b/firstleg/src/main/resources/db/finance-fee-source-migration.sql index f5aea388d..1e773c1e7 100644 --- a/firstleg/src/main/resources/db/finance-fee-source-migration.sql +++ b/firstleg/src/main/resources/db/finance-fee-source-migration.sql @@ -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 diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 46612bca3..751744485 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -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', diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 2b3f9dbc9..49f3f525c 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalFinanceFeeSourceControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalFinanceFeeSourceControllerTest.java index 6241e98b7..175b42634 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalFinanceFeeSourceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalFinanceFeeSourceControllerTest.java @@ -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; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FinanceFeeSourceMigrationContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FinanceFeeSourceMigrationContractTest.java index e2b22d5cc..4a181a7eb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FinanceFeeSourceMigrationContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FinanceFeeSourceMigrationContractTest.java @@ -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迁移失败:入库费用供应商快照字段不完整", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceCommandServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceCommandServiceTest.java new file mode 100644 index 000000000..d6f0abd28 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceCommandServiceTest.java @@ -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))); + } +} diff --git a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql index df24054b9..4508de070 100644 --- a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql +++ b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql @@ -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; diff --git a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java index 231d77d05..2190fbe70 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java @@ -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()"))); }